Browse Source

Customer link is undefined (#349)

* Send share links with the address the installation actually answers on

An emailed invoice or inspection link built its address from

  NEXT_PUBLIC_APP_URL || VERCEL_URL ? `https://${VERCEL_URL}` : localhost

which reads as (a || b) ? c : d. It tested one address and printed the
other, so anywhere VERCEL_URL does not exist, which is every self-hosted
installation, the customer received https://undefined/share/... The link
shown in the share dialog was always right because the browser builds that
one from window.location.origin, so the fault only showed once the mail had
gone out.

Both links now come from getAppBaseUrl(), which prefers the configured
address, falls back to the deployment hostname on a Vercel preview, and
only then to localhost.

* Build every server-side link from the one helper

Ten other places wrote out the same address expression by hand. They were
all parenthesised correctly, so none of them were broken, but the typo that
sent share links to https://undefined was one careless edit away from coming
back in any of them.

They now call getAppBaseUrl(). The portal verify route kept its own fallback
to the origin the request arrived on, which is a better guess than the dev
server for a redirect, so the helper takes the fallback as an argument.
Bernt Christian Egeland 3 weeks ago
parent
commit
bf83e5da5b

+ 80 - 0
src/__tests__/lib/app-url.test.ts

@@ -0,0 +1,80 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import { getAppBaseUrl } from '@/lib/app-url'
+
+/**
+ * A self-hosted install sets NEXT_PUBLIC_APP_URL and has no VERCEL_URL. The
+ * expression this replaced tested the first and printed the second, so every
+ * emailed share link left the building as "https://undefined/share/...".
+ */
+describe('getAppBaseUrl', () => {
+  const appUrl = process.env.NEXT_PUBLIC_APP_URL
+  const vercelUrl = process.env.VERCEL_URL
+
+  afterEach(() => {
+    restore('NEXT_PUBLIC_APP_URL', appUrl)
+    restore('VERCEL_URL', vercelUrl)
+  })
+
+  function restore(key: string, value: string | undefined) {
+    if (value === undefined) delete process.env[key]
+    else process.env[key] = value
+  }
+
+  it('uses the configured address on a self-hosted install', () => {
+    process.env.NEXT_PUBLIC_APP_URL = 'https://workshop.example.com'
+    delete process.env.VERCEL_URL
+    expect(getAppBaseUrl()).toBe('https://workshop.example.com')
+  })
+
+  it('never prints undefined when only the configured address is set', () => {
+    process.env.NEXT_PUBLIC_APP_URL = 'https://workshop.example.com'
+    delete process.env.VERCEL_URL
+    expect(getAppBaseUrl()).not.toContain('undefined')
+  })
+
+  it('prefers the configured address over the deployment hostname', () => {
+    process.env.NEXT_PUBLIC_APP_URL = 'https://workshop.example.com'
+    process.env.VERCEL_URL = 'preview-abc123.vercel.app'
+    expect(getAppBaseUrl()).toBe('https://workshop.example.com')
+  })
+
+  it('falls back to the deployment hostname on a preview', () => {
+    delete process.env.NEXT_PUBLIC_APP_URL
+    process.env.VERCEL_URL = 'preview-abc123.vercel.app'
+    expect(getAppBaseUrl()).toBe('https://preview-abc123.vercel.app')
+  })
+
+  it('falls back to localhost when nothing is configured', () => {
+    delete process.env.NEXT_PUBLIC_APP_URL
+    delete process.env.VERCEL_URL
+    expect(getAppBaseUrl()).toBe('http://localhost:3000')
+  })
+
+  it('does not leave a trailing slash to double up on the path', () => {
+    process.env.NEXT_PUBLIC_APP_URL = 'https://workshop.example.com/'
+    delete process.env.VERCEL_URL
+    expect(`${getAppBaseUrl()}/share/invoice/org/token`).toBe(
+      'https://workshop.example.com/share/invoice/org/token'
+    )
+  })
+
+  it("uses the caller's fallback when nothing is configured", () => {
+    // The portal verify route sends people back to the address that reached
+    // it, which beats guessing at the dev server.
+    delete process.env.NEXT_PUBLIC_APP_URL
+    delete process.env.VERCEL_URL
+    expect(getAppBaseUrl('https://portal.example.com')).toBe('https://portal.example.com')
+  })
+
+  it("still prefers the configured address over the caller's fallback", () => {
+    process.env.NEXT_PUBLIC_APP_URL = 'https://workshop.example.com'
+    delete process.env.VERCEL_URL
+    expect(getAppBaseUrl('https://portal.example.com')).toBe('https://workshop.example.com')
+  })
+
+  it('ignores an address set to whitespace', () => {
+    process.env.NEXT_PUBLIC_APP_URL = '   '
+    delete process.env.VERCEL_URL
+    expect(getAppBaseUrl()).toBe('http://localhost:3000')
+  })
+})

+ 2 - 3
src/app/(authenticated)/settings/customer-portal/page.tsx

@@ -9,6 +9,7 @@ import {
   isValidPortalBackgroundType,
   isValidPortalBackgroundType,
   type PortalBackgroundType,
   type PortalBackgroundType,
 } from '@/features/portal/portal-backgrounds'
 } from '@/features/portal/portal-backgrounds'
+import { getAppBaseUrl } from '@/lib/app-url'
 
 
 export default async function CustomerPortalSettingsPage() {
 export default async function CustomerPortalSettingsPage() {
   const data = await getLayoutData()
   const data = await getLayoutData()
@@ -53,9 +54,7 @@ export default async function CustomerPortalSettingsPage() {
 
 
   const settingMap = new Map(settings.map((s) => [s.key, s.value]))
   const settingMap = new Map(settings.map((s) => [s.key, s.value]))
 
 
-  const appUrl =
-    process.env.NEXT_PUBLIC_APP_URL ||
-    (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+  const appUrl = getAppBaseUrl()
 
 
   const rawBgType = settingMap.get(SETTING_KEYS.PORTAL_BACKGROUND_TYPE)
   const rawBgType = settingMap.get(SETTING_KEYS.PORTAL_BACKGROUND_TYPE)
   const backgroundType: PortalBackgroundType = isValidPortalBackgroundType(rawBgType)
   const backgroundType: PortalBackgroundType = isValidPortalBackgroundType(rawBgType)

+ 3 - 8
src/app/(public)/portal/[orgId]/auth/verify/route.ts

@@ -3,19 +3,14 @@ import { randomBytes } from 'crypto'
 import { db } from '@/lib/db'
 import { db } from '@/lib/db'
 import { CUSTOMER_SESSION_COOKIE, CUSTOMER_SESSION_DURATION } from '@/lib/customer-session'
 import { CUSTOMER_SESSION_COOKIE, CUSTOMER_SESSION_DURATION } from '@/lib/customer-session'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { resolvePortalOrg } from '@/lib/portal-slug'
-
-function getBaseUrl(requestUrl: string): string {
-  // Prefer configured app URL, fall back to request origin
-  if (process.env.NEXT_PUBLIC_APP_URL) return process.env.NEXT_PUBLIC_APP_URL
-  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
-  return new URL(requestUrl).origin
-}
+import { getAppBaseUrl } from '@/lib/app-url'
 
 
 export async function GET(request: Request, { params }: { params: Promise<{ orgId: string }> }) {
 export async function GET(request: Request, { params }: { params: Promise<{ orgId: string }> }) {
   const { orgId: orgParam } = await params
   const { orgId: orgParam } = await params
   const { searchParams } = new URL(request.url)
   const { searchParams } = new URL(request.url)
   const token = searchParams.get('token')
   const token = searchParams.get('token')
-  const baseUrl = getBaseUrl(request.url)
+  // Nothing configured: the address that reached us is the one to send them back to.
+  const baseUrl = getAppBaseUrl(new URL(request.url).origin)
 
 
   const loginUrl = `${baseUrl}/portal/${orgParam}/auth/login`
   const loginUrl = `${baseUrl}/portal/${orgParam}/auth/login`
 
 

+ 2 - 3
src/app/(public)/share/inspection/[orgId]/[token]/page.tsx

@@ -4,6 +4,7 @@ import { InspectionView } from './inspection-view'
 import { getFeatures } from '@/lib/features'
 import { getFeatures } from '@/lib/features'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import type { Metadata } from 'next'
 import type { Metadata } from 'next'
+import { getAppBaseUrl } from '@/lib/app-url'
 
 
 export const revalidate = 60
 export const revalidate = 60
 
 
@@ -106,9 +107,7 @@ export default async function PublicInspectionPage({
 
 
   const primaryColor = settingsMap['invoice.primaryColor'] || '#d97706'
   const primaryColor = settingsMap['invoice.primaryColor'] || '#d97706'
 
 
-  const appUrl =
-    process.env.NEXT_PUBLIC_APP_URL ||
-    (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+  const appUrl = getAppBaseUrl()
   const portalSlug = org?.portalSlug
   const portalSlug = org?.portalSlug
   const portalEnabled = settingsMap['portal.enabled'] === 'true'
   const portalEnabled = settingsMap['portal.enabled'] === 'true'
   const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
   const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined

+ 2 - 3
src/app/(public)/share/invoice/[orgId]/[token]/page.tsx

@@ -12,6 +12,7 @@ import { headers } from 'next/headers'
 import type { Metadata } from 'next'
 import type { Metadata } from 'next'
 import { getOrgTelegramBotUsername } from '@/lib/telegram'
 import { getOrgTelegramBotUsername } from '@/lib/telegram'
 import { offeredPaymentProviders } from '@/features/integrations/Lib/payments'
 import { offeredPaymentProviders } from '@/features/integrations/Lib/payments'
+import { getAppBaseUrl } from '@/lib/app-url'
 
 
 /** Rewrites /api/protected/files/[orgId]/[category]/[filename] to /api/public/files/[token]/[category]/[filename] */
 /** Rewrites /api/protected/files/[orgId]/[category]/[filename] to /api/public/files/[token]/[category]/[filename] */
 function toPublicFileUrl(fileUrl: string, token: string): string {
 function toPublicFileUrl(fileUrl: string, token: string): string {
@@ -130,9 +131,7 @@ export default async function PublicInvoicePage({
     settingsMap['payment.termsOfSaleUrl'] ||
     settingsMap['payment.termsOfSaleUrl'] ||
     (settingsMap['payment.termsOfSale'] ? `/share/terms/${orgId}` : undefined)
     (settingsMap['payment.termsOfSale'] ? `/share/terms/${orgId}` : undefined)
 
 
-  const appUrl =
-    process.env.NEXT_PUBLIC_APP_URL ||
-    (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+  const appUrl = getAppBaseUrl()
   const portalSlug = org?.portalSlug
   const portalSlug = org?.portalSlug
   const portalEnabled = settingsMap['portal.enabled'] === 'true'
   const portalEnabled = settingsMap['portal.enabled'] === 'true'
   const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
   const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined

+ 2 - 3
src/app/(public)/share/quote/[orgId]/[token]/page.tsx

@@ -12,6 +12,7 @@ import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { headers } from 'next/headers'
 import { headers } from 'next/headers'
 import type { Metadata } from 'next'
 import type { Metadata } from 'next'
 import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
 import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
+import { getAppBaseUrl } from '@/lib/app-url'
 
 
 export const revalidate = 60
 export const revalidate = 60
 
 
@@ -223,9 +224,7 @@ export default async function PublicQuotePage({
     layoutConfig,
     layoutConfig,
   })
   })
 
 
-  const appUrl =
-    process.env.NEXT_PUBLIC_APP_URL ||
-    (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+  const appUrl = getAppBaseUrl()
   const portalSlug = org?.portalSlug
   const portalSlug = org?.portalSlug
   const portalEnabled = settingsMap['portal.enabled'] === 'true'
   const portalEnabled = settingsMap['portal.enabled'] === 'true'
   const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
   const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined

+ 2 - 3
src/app/api/protected/services/[id]/pdf/route.ts

@@ -15,6 +15,7 @@ import { markInvoiceIssued } from '@/features/onboarding/Lib/markInvoiceIssued'
 import { getOrgTelegramBotUsername } from '@/lib/telegram'
 import { getOrgTelegramBotUsername } from '@/lib/telegram'
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { assembleInvoicePrint, invoiceNumberOf } from '@/features/invoices/Lib/assembleInvoicePrint'
 import { assembleInvoicePrint, invoiceNumberOf } from '@/features/invoices/Lib/assembleInvoicePrint'
+import { getAppBaseUrl } from '@/lib/app-url'
 
 
 export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
 export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
   try {
   try {
@@ -101,9 +102,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
       torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri()
       torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri()
     }
     }
 
 
-    const appUrl =
-      process.env.NEXT_PUBLIC_APP_URL ||
-      (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+    const appUrl = getAppBaseUrl()
     const portalSlug = org?.portalSlug
     const portalSlug = org?.portalSlug
     const portalEnabled = settingsMap['portal.enabled'] === 'true'
     const portalEnabled = settingsMap['portal.enabled'] === 'true'
     const portalUrl = portalEnabled
     const portalUrl = portalEnabled

+ 2 - 3
src/app/api/public/portal/[orgId]/auth/request/route.ts

@@ -6,6 +6,7 @@ import { sendOrgMail, getOrgFromAddress } from '@/lib/email'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { MAGIC_LINK_DURATION } from '@/lib/customer-session'
 import { MAGIC_LINK_DURATION } from '@/lib/customer-session'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { resolvePortalOrg } from '@/lib/portal-slug'
+import { getAppBaseUrl } from '@/lib/app-url'
 
 
 function escapeHtml(value: string): string {
 function escapeHtml(value: string): string {
   return value
   return value
@@ -80,9 +81,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ org
     })
     })
 
 
     // Send email - use the URL param (slug) so the verify link matches the user's URL
     // Send email - use the URL param (slug) so the verify link matches the user's URL
-    const appUrl =
-      process.env.NEXT_PUBLIC_APP_URL ||
-      (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+    const appUrl = getAppBaseUrl()
 
 
     const magicLinkUrl = `${appUrl}/portal/${orgParam}/auth/verify?token=${token}`
     const magicLinkUrl = `${appUrl}/portal/${orgParam}/auth/verify?token=${token}`
     const fromAddress = await getOrgFromAddress(orgId)
     const fromAddress = await getOrgFromAddress(orgId)

+ 2 - 3
src/app/api/public/share/inspection/[orgId]/[token]/pdf/route.ts

@@ -12,6 +12,7 @@ import { getFeatures } from '@/lib/features'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { resolvePortalOrg } from '@/lib/portal-slug'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
+import { getAppBaseUrl } from '@/lib/app-url'
 
 
 export async function GET(
 export async function GET(
   _request: Request,
   _request: Request,
@@ -124,9 +125,7 @@ export async function GET(
       headerStyle: settingsMap['invoice.headerStyle'] || 'standard',
       headerStyle: settingsMap['invoice.headerStyle'] || 'standard',
     }
     }
 
 
-    const appUrl =
-      process.env.NEXT_PUBLIC_APP_URL ||
-      (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+    const appUrl = getAppBaseUrl()
     const portalSlug = org?.portalSlug
     const portalSlug = org?.portalSlug
     const portalEnabled = settingsMap['portal.enabled'] === 'true'
     const portalEnabled = settingsMap['portal.enabled'] === 'true'
     const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
     const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined

+ 2 - 3
src/app/api/public/share/quote/[orgId]/[token]/pdf/route.ts

@@ -15,6 +15,7 @@ import { documentLogoPath } from '@/features/invoice-designer/Lib/documentLogo'
 import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { mergeWithDefaults } from '@/features/settings/Schema/invoiceLayoutSchema'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
 import { getCustomFieldsForPrint } from '@/features/custom-fields/Lib/getCustomFieldsForPrint'
+import { getAppBaseUrl } from '@/lib/app-url'
 
 
 export async function GET(
 export async function GET(
   _request: Request,
   _request: Request,
@@ -204,9 +205,7 @@ export async function GET(
       logoSize: Number(settingsMap['quote.logoSize']) || 100,
       logoSize: Number(settingsMap['quote.logoSize']) || 100,
     }
     }
 
 
-    const appUrl =
-      process.env.NEXT_PUBLIC_APP_URL ||
-      (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+    const appUrl = getAppBaseUrl()
     const portalSlug = org?.portalSlug
     const portalSlug = org?.portalSlug
     const portalEnabled = settingsMap['portal.enabled'] === 'true'
     const portalEnabled = settingsMap['portal.enabled'] === 'true'
     const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
     const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined

+ 3 - 2
src/features/email/Actions/emailActions.ts

@@ -26,6 +26,7 @@ import { issueInvoice } from '@/features/invoices/Lib/issueInvoice'
 import { assembleInvoicePrint, invoiceNumberOf } from '@/features/invoices/Lib/assembleInvoicePrint'
 import { assembleInvoicePrint, invoiceNumberOf } from '@/features/invoices/Lib/assembleInvoicePrint'
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
+import { getAppBaseUrl } from '@/lib/app-url'
 
 
 async function getWorkshopSettings(organizationId: string) {
 async function getWorkshopSettings(organizationId: string) {
   const [settings, org] = await Promise.all([
   const [settings, org] = await Promise.all([
@@ -319,7 +320,7 @@ export async function sendInvoiceEmail(input: {
 
 
       // Build public invoice link if token exists
       // Build public invoice link if token exists
       const publicLink = owned.publicToken
       const publicLink = owned.publicToken
-        ? `${process.env.NEXT_PUBLIC_APP_URL || process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000'}/share/invoice/${organizationId}/${owned.publicToken}`
+        ? `${getAppBaseUrl()}/share/invoice/${organizationId}/${owned.publicToken}`
         : null
         : null
 
 
       const from = await getOrgFromAddress(organizationId)
       const from = await getOrgFromAddress(organizationId)
@@ -459,7 +460,7 @@ export async function sendInspectionEmail(input: {
 
 
       // Build public link if token exists
       // Build public link if token exists
       const publicLink = inspection.publicToken
       const publicLink = inspection.publicToken
-        ? `${process.env.NEXT_PUBLIC_APP_URL || process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000'}/share/inspection/${organizationId}/${inspection.publicToken}`
+        ? `${getAppBaseUrl()}/share/inspection/${organizationId}/${inspection.publicToken}`
         : null
         : null
 
 
       const from = await getOrgFromAddress(organizationId)
       const from = await getOrgFromAddress(organizationId)

+ 2 - 3
src/features/invoices/Pdf/buildInvoicePdfBuffer.ts

@@ -21,6 +21,7 @@ import { getTorqvoiceLogoDataUri } from '@/lib/torqvoice-branding'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { resolveCustomerLocale } from '@/i18n/locale-from-request'
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { loadPrintLabels } from '@/features/invoice-designer/Pdf/printLabels'
 import { assembleInvoicePrint, invoiceNumberOf } from '../Lib/assembleInvoicePrint'
 import { assembleInvoicePrint, invoiceNumberOf } from '../Lib/assembleInvoicePrint'
+import { getAppBaseUrl } from '@/lib/app-url'
 
 
 export async function buildInvoicePdfBuffer(
 export async function buildInvoicePdfBuffer(
   serviceRecordId: string,
   serviceRecordId: string,
@@ -40,9 +41,7 @@ export async function buildInvoicePdfBuffer(
     torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri()
     torqvoiceLogoDataUri = await getTorqvoiceLogoDataUri()
   }
   }
 
 
-  const appUrl =
-    process.env.NEXT_PUBLIC_APP_URL ||
-    (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : 'http://localhost:3000')
+  const appUrl = getAppBaseUrl()
   const portalSlug = org?.portalSlug
   const portalSlug = org?.portalSlug
   const portalEnabled = settingsMap['portal.enabled'] === 'true'
   const portalEnabled = settingsMap['portal.enabled'] === 'true'
   const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined
   const portalUrl = portalEnabled ? `${appUrl}/portal/${portalSlug || orgId}` : undefined

+ 24 - 0
src/lib/app-url.ts

@@ -0,0 +1,24 @@
+/**
+ * The address this installation answers on, for links built on the server.
+ *
+ * A link the browser builds can use window.location.origin and is always
+ * right. A link an email or a PDF builds has no browser to ask, so it has to
+ * be told: NEXT_PUBLIC_APP_URL is that address on a self-hosted install, and
+ * VERCEL_URL stands in on a preview deployment, which has no fixed hostname.
+ *
+ * Written down once because the expression is easy to get wrong: `a || b ? c
+ * : d` reads as `(a || b) ? c : d`, which tests one address and then prints
+ * the other. That typo sent every emailed share link out as
+ * "https://undefined/share/..." on installations that are not on Vercel.
+ *
+ * `fallback` is what to use when neither is configured. It defaults to the
+ * dev server, which is the right guess for a link built while someone is
+ * working locally; a request handler that knows the origin it was called on
+ * should pass that instead, since it is the address that actually reached us.
+ */
+export function getAppBaseUrl(fallback = 'http://localhost:3000'): string {
+  const configured = process.env.NEXT_PUBLIC_APP_URL?.trim()
+  if (configured) return configured.replace(/\/+$/, '')
+  if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`
+  return fallback
+}