소스 검색

Print documents in the workshop language (#470)

Bernt Christian Egeland 5 일 전
부모
커밋
d14daca2b8

+ 2 - 2
messages/de/settings.json

@@ -1103,8 +1103,8 @@
     "formatCodeLabel": "Code: {value}"
   },
   "localization": {
-    "languageTitle": "Sprache",
-    "languageDescription": "Wählen Sie die Anzeigesprache der Anwendung.",
+    "languageTitle": "Werkstattsprache",
+    "languageDescription": "Rechnungen, Angebote, Arbeitsaufträge und E-Mails an Ihre Kunden werden in dieser Sprache erstellt. Um nur Ihre eigene Anzeigesprache zu ändern, nutzen Sie die Sprachauswahl in Ihrem Benutzermenü.",
     "language": "Sprache",
     "forceCustomerLocale": "Diese Sprache für kundenseitige Seiten erzwingen",
     "forceCustomerLocaleHint": "Wenn aktiviert, erscheinen das Kundenportal sowie geteilte Rechnungen, Angebote und Inspektionen in der Sprache Ihrer Werkstatt, unabhängig von den Browsereinstellungen des Besuchers. Wenn deaktiviert, folgen kundenseitige Seiten der Browsersprache des Besuchers.",

+ 2 - 2
messages/en/settings.json

@@ -1103,8 +1103,8 @@
     "formatCodeLabel": "Code: {value}"
   },
   "localization": {
-    "languageTitle": "Language",
-    "languageDescription": "Choose the display language for the application.",
+    "languageTitle": "Workshop language",
+    "languageDescription": "Invoices, quotes, work orders and emails to your customers are written in this language. To change only the language you see, use the language picker in your user menu.",
     "language": "Language",
     "forceCustomerLocale": "Force this language on customer-facing pages",
     "forceCustomerLocaleHint": "When enabled, the customer portal and shared invoices, quotes and inspections will appear in your workshop's language regardless of the visitor's browser settings. When disabled, customer-facing pages follow the visitor's browser language.",

+ 2 - 2
messages/es/settings.json

@@ -1103,8 +1103,8 @@
     "formatCodeLabel": "Código: {value}"
   },
   "localization": {
-    "languageTitle": "Idioma",
-    "languageDescription": "Elija el idioma de visualización de la aplicación.",
+    "languageTitle": "Idioma del taller",
+    "languageDescription": "Las facturas, presupuestos, órdenes de trabajo y correos a sus clientes se escriben en este idioma. Para cambiar solo el idioma que usted ve, use el selector de idioma de su menú de usuario.",
     "language": "Idioma",
     "forceCustomerLocale": "Forzar este idioma en las páginas dirigidas al cliente",
     "forceCustomerLocaleHint": "Cuando está activado, el portal de clientes y las facturas, presupuestos e inspecciones compartidas aparecen en el idioma de su taller, independientemente de la configuración del navegador del visitante. Cuando está desactivado, las páginas para clientes siguen el idioma del navegador del visitante.",

+ 2 - 2
messages/fr/settings.json

@@ -1103,8 +1103,8 @@
     "formatCodeLabel": "Code: {value}"
   },
   "localization": {
-    "languageTitle": "Langue",
-    "languageDescription": "Choisissez la langue d'affichage de l'application.",
+    "languageTitle": "Langue de l'atelier",
+    "languageDescription": "Les factures, devis, ordres de travail et e-mails envoyés à vos clients sont rédigés dans cette langue. Pour changer uniquement la langue que vous voyez, utilisez le sélecteur de langue de votre menu utilisateur.",
     "language": "Langue",
     "forceCustomerLocale": "Forcer cette langue sur les pages destinées aux clients",
     "forceCustomerLocaleHint": "Lorsqu'il est activé, le portail client et les factures, devis et inspections partagés s'affichent dans la langue de votre atelier, quels que soient les paramètres du navigateur du visiteur. Lorsqu'il est désactivé, les pages destinées aux clients suivent la langue du navigateur du visiteur.",

+ 2 - 2
messages/it/settings.json

@@ -1103,8 +1103,8 @@
     "formatCodeLabel": "Codice: {value}"
   },
   "localization": {
-    "languageTitle": "Lingua",
-    "languageDescription": "Scegli la lingua di visualizzazione dell'applicazione.",
+    "languageTitle": "Lingua dell'officina",
+    "languageDescription": "Fatture, preventivi, ordini di lavoro ed email ai clienti sono scritti in questa lingua. Per cambiare solo la lingua che vedi, usa il selettore della lingua nel tuo menu utente.",
     "language": "Lingua",
     "forceCustomerLocale": "Forza questa lingua sulle pagine rivolte al cliente",
     "forceCustomerLocaleHint": "Quando attivato, il portale clienti e le fatture, i preventivi e le ispezioni condivise vengono mostrati nella lingua della tua officina, indipendentemente dalle impostazioni del browser del visitatore. Quando disattivato, le pagine rivolte ai clienti seguono la lingua del browser del visitatore.",

+ 2 - 2
messages/lt/settings.json

@@ -1103,8 +1103,8 @@
     "formatCodeLabel": "Kodas: {value}"
   },
   "localization": {
-    "languageTitle": "Kalba",
-    "languageDescription": "Pasirinkite programos rodymo kalbą.",
+    "languageTitle": "Dirbtuvių kalba",
+    "languageDescription": "Sąskaitos, pasiūlymai, darbo užsakymai ir el. laiškai klientams rašomi šia kalba. Norėdami pakeisti tik jums rodomą kalbą, naudokite kalbos pasirinkimą savo naudotojo meniu.",
     "language": "Kalba",
     "forceCustomerLocale": "Priverstinai naudoti šią kalbą klientams matomuose puslapiuose",
     "forceCustomerLocaleHint": "Kai įjungta, klientų portalas ir bendrinos sąskaitos, pasiūlymai bei apžiūros rodomi jūsų dirbtuvių kalba, nepriklausomai nuo lankytojo naršyklės nustatymų. Kai išjungta, klientams skirti puslapiai naudoja lankytojo naršyklės kalbą.",

+ 2 - 2
messages/nb/settings.json

@@ -1103,8 +1103,8 @@
     "formatCodeLabel": "Kode: {value}"
   },
   "localization": {
-    "languageTitle": "Språk",
-    "languageDescription": "Velg visningsspråk for applikasjonen.",
+    "languageTitle": "Verkstedets språk",
+    "languageDescription": "Fakturaer, tilbud, arbeidsordrer og e-post til kundene dine skrives på dette språket. Vil du bare endre språket du selv ser, bruker du språkvalget i brukermenyen din.",
     "language": "Språk",
     "forceCustomerLocale": "Tving dette språket på kundesider",
     "forceCustomerLocaleHint": "Når aktivert vises kundeportalen og delte fakturaer, tilbud og inspeksjoner på verkstedets språk uavhengig av besøkendes nettleserinnstillinger. Når deaktivert, følger kundesidene besøkendes nettleserspråk.",

+ 2 - 2
messages/nl/settings.json

@@ -1103,8 +1103,8 @@
     "formatCodeLabel": "Code: {value}"
   },
   "localization": {
-    "languageTitle": "Taal",
-    "languageDescription": "Kies de weergavetaal van de applicatie.",
+    "languageTitle": "Taal van de werkplaats",
+    "languageDescription": "Facturen, offertes, werkorders en e-mails aan uw klanten worden in deze taal opgesteld. Wilt u alleen de taal wijzigen die u zelf ziet, gebruik dan de taalkeuze in uw gebruikersmenu.",
     "language": "Taal",
     "forceCustomerLocale": "Forceer deze taal op klantgerichte pagina's",
     "forceCustomerLocaleHint": "Wanneer ingeschakeld, verschijnen het klantenportaal en gedeelde facturen, offertes en inspecties in de taal van uw werkplaats, ongeacht de browserinstellingen van de bezoeker. Wanneer uitgeschakeld, volgen klantgerichte pagina's de browsertaal van de bezoeker.",

+ 2 - 2
messages/pl/settings.json

@@ -1103,8 +1103,8 @@
     "formatCodeLabel": "Kod: {value}"
   },
   "localization": {
-    "languageTitle": "Język",
-    "languageDescription": "Wybierz język wyświetlania aplikacji.",
+    "languageTitle": "Język warsztatu",
+    "languageDescription": "Faktury, oferty, zlecenia i e-maile do klientów są tworzone w tym języku. Aby zmienić tylko język, który widzisz, użyj wyboru języka w menu użytkownika.",
     "language": "Język",
     "forceCustomerLocale": "Wymuś ten język na stronach dla klientów",
     "forceCustomerLocaleHint": "Po włączeniu portal klienta oraz udostępnione faktury, oferty i inspekcje wyświetlają się w języku Twojego warsztatu, niezależnie od ustawień przeglądarki odwiedzającego. Gdy wyłączone, strony dla klientów używają języka przeglądarki odwiedzającego.",

+ 2 - 2
messages/pt-BR/settings.json

@@ -1103,8 +1103,8 @@
     "formatCodeLabel": "Código: {value}"
   },
   "localization": {
-    "languageTitle": "Idioma",
-    "languageDescription": "Escolha o idioma de exibição do aplicativo.",
+    "languageTitle": "Idioma da oficina",
+    "languageDescription": "Faturas, orçamentos, ordens de serviço e e-mails para seus clientes são escritos neste idioma. Para mudar apenas o idioma que você vê, use o seletor de idioma no seu menu de usuário.",
     "language": "Idioma",
     "forceCustomerLocale": "Forçar este idioma nas páginas voltadas ao cliente",
     "forceCustomerLocaleHint": "Quando ativado, o portal do cliente e as faturas, orçamentos e inspeções compartilhadas aparecem no idioma da sua oficina, independentemente das configurações do navegador do visitante. Quando desativado, as páginas voltadas aos clientes seguem o idioma do navegador do visitante.",

+ 2 - 2
messages/pt-PT/settings.json

@@ -1103,8 +1103,8 @@
     "formatCodeLabel": "Código: {value}"
   },
   "localization": {
-    "languageTitle": "Idioma",
-    "languageDescription": "Escolha o idioma de apresentação da aplicação.",
+    "languageTitle": "Idioma da oficina",
+    "languageDescription": "As faturas, orçamentos, ordens de trabalho e e-mails para os seus clientes são escritos neste idioma. Para mudar apenas o idioma que vê, use o seletor de idioma no seu menu de utilizador.",
     "language": "Idioma",
     "forceCustomerLocale": "Forçar este idioma nas páginas destinadas aos clientes",
     "forceCustomerLocaleHint": "Quando ativado, o portal do cliente e as faturas, orçamentos e inspeções partilhados aparecem no idioma da sua oficina, independentemente das definições do navegador do visitante. Quando desativado, as páginas destinadas aos clientes seguem o idioma do navegador do visitante.",

+ 2 - 2
messages/ru/settings.json

@@ -1103,8 +1103,8 @@
     "formatCodeLabel": "Код: {value}"
   },
   "localization": {
-    "languageTitle": "Язык",
-    "languageDescription": "Выберите язык приложения.",
+    "languageTitle": "Язык мастерской",
+    "languageDescription": "Счета, сметы, заказ-наряды и письма клиентам составляются на этом языке. Чтобы изменить только язык, который видите вы, используйте выбор языка в меню пользователя.",
     "language": "Язык",
     "forceCustomerLocale": "Применять этот язык на страницах для клиентов",
     "forceCustomerLocaleHint": "Когда включено, портал клиента и общие счета, предложения и инспекции отображаются на языке вашей мастерской, независимо от настроек браузера посетителя. Когда отключено, клиентские страницы используют язык браузера посетителя.",

+ 2 - 2
messages/tr/settings.json

@@ -1103,8 +1103,8 @@
     "formatCodeLabel": "Kod: {value}"
   },
   "localization": {
-    "languageTitle": "Dil",
-    "languageDescription": "Uygulamanın görüntüleme dilini seçin.",
+    "languageTitle": "Atölye dili",
+    "languageDescription": "Faturalar, teklifler, iş emirleri ve müşterilerinize giden e-postalar bu dilde yazılır. Yalnızca kendi gördüğünüz dili değiştirmek için kullanıcı menünüzdeki dil seçiciyi kullanın.",
     "language": "Dil",
     "forceCustomerLocale": "Bu dili müşteriye yönelik sayfalarda zorla",
     "forceCustomerLocaleHint": "Etkinleştirildiğinde, müşteri portalı ve paylaşılan faturalar, teklifler ve incelemeler, ziyaretçinin tarayıcı ayarlarından bağımsız olarak atölyenizin dilinde görüntülenir. Devre dışı bırakıldığında, müşteriye yönelik sayfalar ziyaretçinin tarayıcı dilini kullanır.",

+ 1 - 1
src/__tests__/features/email/inspection-email-certificate.test.ts

@@ -40,7 +40,7 @@ vi.mock('@/features/invoices/Lib/assembleInvoicePrint', () => ({
 vi.mock('@/features/invoices/Pdf/buildInvoicePdfBuffer', () => ({ renderInvoicePdf: vi.fn() }))
 vi.mock('@/features/quotes/Pdf/buildQuotePdfBuffer', () => ({ buildQuotePdfBuffer: vi.fn() }))
 vi.mock('@/i18n/locale-from-request', () => ({
-  resolveCustomerLocale: vi.fn().mockResolvedValue('nb'),
+  resolveDocumentLocale: vi.fn().mockResolvedValue('nb'),
 }))
 vi.mock('@/lib/app-url', () => ({ getAppBaseUrl: () => 'https://app.example' }))
 

+ 5 - 5
src/__tests__/features/email/send-templated-mail.test.ts

@@ -27,9 +27,9 @@ vi.mock('@/lib/email', () => ({
   getOrgFromAddress: (...args: unknown[]) => getOrgFromAddress(...args),
 }))
 
-const resolveCustomerLocale = vi.fn().mockResolvedValue('en')
+const resolveDocumentLocale = vi.fn().mockResolvedValue('en')
 vi.mock('@/i18n/locale-from-request', () => ({
-  resolveCustomerLocale: (...args: unknown[]) => resolveCustomerLocale(...args),
+  resolveDocumentLocale: (...args: unknown[]) => resolveDocumentLocale(...args),
 }))
 
 const { sendTemplatedMail } = await import('@/features/email/Lib/sendTemplatedMail')
@@ -51,7 +51,7 @@ function lastMail(): SentMail {
 beforeEach(() => {
   sendOrgMail.mockClear()
   getOrgFromAddress.mockClear()
-  resolveCustomerLocale.mockClear()
+  resolveDocumentLocale.mockClear()
 })
 
 describe('sendTemplatedMail', () => {
@@ -114,13 +114,13 @@ describe('sendTemplatedMail', () => {
       locale: 'nb',
       context: { message: 'Hei' },
     })
-    expect(resolveCustomerLocale).not.toHaveBeenCalled()
+    expect(resolveDocumentLocale).not.toHaveBeenCalled()
 
     await sendTemplatedMail('org', {
       kind: 'message',
       to: 'ola@example.com',
       context: { message: 'Hi' },
     })
-    expect(resolveCustomerLocale).toHaveBeenCalledWith('org', null)
+    expect(resolveDocumentLocale).toHaveBeenCalledWith('org')
   })
 })

+ 103 - 0
src/__tests__/i18n/document-locale.test.ts

@@ -0,0 +1,103 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+// The workshop's saved settings, and the request the document is made from.
+let settings: { key: string; value: string }[] = []
+let cookieLocale: string | undefined
+let acceptLanguage: string | null = null
+let outsideRequest = false
+
+vi.mock('@/lib/db', () => ({
+  db: { appSetting: { findMany: vi.fn(async () => settings) } },
+}))
+
+vi.mock('next/headers', () => ({
+  cookies: async () => {
+    if (outsideRequest) throw new Error('cookies was called outside a request scope')
+    return {
+      get: (name: string) =>
+        name === 'locale' && cookieLocale ? { value: cookieLocale } : undefined,
+    }
+  },
+  headers: async () => {
+    if (outsideRequest) throw new Error('headers was called outside a request scope')
+    return { get: (name: string) => (name === 'accept-language' ? acceptLanguage : null) }
+  },
+}))
+
+const { resolveCustomerLocale, resolveDocumentLocale } = await import('@/i18n/locale-from-request')
+
+function workshop(locale?: string, forced = false) {
+  settings = []
+  if (locale) settings.push({ key: 'workshop.locale', value: locale })
+  if (forced) settings.push({ key: 'workshop.forceCustomerLocale', value: 'true' })
+}
+
+beforeEach(() => {
+  workshop()
+  cookieLocale = undefined
+  acceptLanguage = null
+  outsideRequest = false
+})
+
+describe('resolveDocumentLocale', () => {
+  it('prints in the workshop language, not the language the user picked for their screens', async () => {
+    workshop('fr')
+    cookieLocale = 'en'
+    acceptLanguage = 'en-US,en;q=0.9'
+    expect(await resolveDocumentLocale('org')).toBe('fr')
+  })
+
+  it('uses the workshop language even when customer pages are not forced to it', async () => {
+    workshop('fr', false)
+    expect(await resolveDocumentLocale('org')).toBe('fr')
+  })
+
+  it('falls back to the language the user picked when the workshop has none saved', async () => {
+    cookieLocale = 'fr'
+    expect(await resolveDocumentLocale('org')).toBe('fr')
+  })
+
+  it('falls back to the browser language, the same one the app screens show', async () => {
+    acceptLanguage = 'fr-FR,fr;q=0.9,en;q=0.8'
+    expect(await resolveDocumentLocale('org')).toBe('fr')
+  })
+
+  it('ignores a saved language that is not one the app has', async () => {
+    workshop('xx')
+    cookieLocale = 'de'
+    expect(await resolveDocumentLocale('org')).toBe('de')
+  })
+
+  it('uses English only outside a request with nothing saved', async () => {
+    outsideRequest = true
+    expect(await resolveDocumentLocale('org')).toBe('en')
+  })
+
+  it('uses the workshop language outside a request, as a scheduled mail does', async () => {
+    outsideRequest = true
+    workshop('nb')
+    expect(await resolveDocumentLocale('org')).toBe('nb')
+  })
+})
+
+describe('resolveCustomerLocale', () => {
+  it('follows the customer browser when the workshop language is not forced', async () => {
+    workshop('fr')
+    expect(await resolveCustomerLocale('org', 'de-DE,de;q=0.9')).toBe('de')
+  })
+
+  it('uses the workshop language when forced', async () => {
+    workshop('fr', true)
+    expect(await resolveCustomerLocale('org', 'de-DE,de;q=0.9')).toBe('fr')
+  })
+
+  it('falls back to the workshop language, not English, when there is no browser to ask', async () => {
+    workshop('fr')
+    expect(await resolveCustomerLocale('org', null)).toBe('fr')
+  })
+
+  it('never reads the viewer cookie', async () => {
+    cookieLocale = 'fr'
+    expect(await resolveCustomerLocale('org', null)).toBe('en')
+  })
+})

+ 3 - 4
src/app/api/protected/inspections/[id]/pdf/route.ts

@@ -1,7 +1,7 @@
 import { NextResponse } from 'next/server'
 import { renderToBuffer } from '@react-pdf/renderer'
 import '@/features/vehicles/Components/invoice-pdf/fonts'
-import { cookies } from 'next/headers'
+import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
 import { InspectionPDF } from '@/features/inspections/Components/InspectionPDF'
@@ -29,9 +29,8 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
       return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
     }
 
-    // Load locale-based PDF translations
-    const cookieStore = await cookies()
-    const locale = cookieStore.get('locale')?.value || 'en'
+    // Load the PDF translations in the workshop's language
+    const locale = await resolveDocumentLocale(ctx.organizationId)
     let pdfMessages: Record<string, Record<string, string>>
     try {
       pdfMessages = (await import(`../../../../../../../messages/${locale}/pdf.json`)).default

+ 3 - 4
src/app/api/protected/quotes/[id]/pdf/route.ts

@@ -1,4 +1,4 @@
-import { cookies } from 'next/headers'
+import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { NextResponse } from 'next/server'
 import { buildQuotePdfBuffer } from '@/features/quotes/Pdf/buildQuotePdfBuffer'
 import { getAuthContext } from '@/lib/get-auth-context'
@@ -17,9 +17,8 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
     }
 
     const { id } = await params
-    // The workshop reads its own copy in its own language; a customer's copy
-    // follows the customer's.
-    const locale = (await cookies()).get('locale')?.value || 'en'
+    // The workshop's language, the one the customer receives it in.
+    const locale = await resolveDocumentLocale(ctx.organizationId)
 
     const pdf = await buildQuotePdfBuffer(id, ctx.organizationId, locale)
     if (!pdf) {

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

@@ -1,5 +1,5 @@
 import { NextResponse } from 'next/server'
-import { cookies } from 'next/headers'
+import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
 import { readFile } from 'fs/promises'
@@ -40,10 +40,8 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
     // in the data, so record it here. Best-effort, never blocks the PDF.
     void markInvoiceIssued(ctx.organizationId, ctx.userId, record.id)
 
-    // The workshop reads its own copy in its own language; the customer's
-    // copy follows the customer's, which the shared renderer resolves.
-    const cookieStore = await cookies()
-    const locale = cookieStore.get('locale')?.value || 'en'
+    // The workshop's language, the one the customer receives it in.
+    const locale = await resolveDocumentLocale(ctx.organizationId)
 
     // Load image attachments as base64 data URIs for PDF embedding
     const imageAttachments: { fileName: string; dataUri: string; description?: string }[] = []

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

@@ -1,5 +1,5 @@
 import { NextResponse } from 'next/server'
-import { cookies } from 'next/headers'
+import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
 import { buildWorkOrderPdfBuffer } from '@/features/invoices/Pdf/buildWorkOrderPdfBuffer'
@@ -25,8 +25,7 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
       return NextResponse.json({ error: 'Record not found' }, { status: 404 })
     }
 
-    const cookieStore = await cookies()
-    const locale = cookieStore.get('locale')?.value || 'en'
+    const locale = await resolveDocumentLocale(ctx.organizationId)
 
     const result = await buildWorkOrderPdfBuffer(owned.id, locale)
     if (!result) {

+ 3 - 4
src/app/api/protected/vehicles/[id]/service-history-pdf/route.ts

@@ -2,7 +2,7 @@ import { resolveCurrencyCode, resolveCurrencyFormat } from '@/lib/currencies'
 import { NextResponse } from 'next/server'
 import { renderToBuffer } from '@react-pdf/renderer'
 import '@/features/vehicles/Components/invoice-pdf/fonts'
-import { cookies } from 'next/headers'
+import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { getAuthContext } from '@/lib/get-auth-context'
 import { db } from '@/lib/db'
 import { ServiceHistoryPDF } from '@/features/vehicles/Components/service-history-pdf/ServiceHistoryPDF'
@@ -25,9 +25,8 @@ export async function GET(_request: Request, { params }: { params: Promise<{ id:
       return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
     }
 
-    // Load locale-based PDF translations
-    const cookieStore = await cookies()
-    const locale = cookieStore.get('locale')?.value || 'en'
+    // Load the PDF translations in the workshop's language
+    const locale = await resolveDocumentLocale(ctx.organizationId)
     let pdfMessages: Record<string, Record<string, string>>
     try {
       pdfMessages = (await import(`../../../../../../../messages/${locale}/pdf.json`)).default

+ 4 - 4
src/features/email/Actions/emailActions.ts

@@ -15,7 +15,7 @@ import {
   buildCustomerCertificatePdf,
   linkedCertificateAttachment,
 } from '@/features/inspections/Pdf/customerCertificatePdf'
-import { resolveCustomerLocale } from '@/i18n/locale-from-request'
+import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { getAppBaseUrl } from '@/lib/app-url'
 import { randomUUID } from 'crypto'
 import { resolveAttachPdf } from '@/features/email/Lib/documentEmail'
@@ -124,7 +124,7 @@ export async function sendQuoteEmail(input: {
       const attachPdf = resolveAttachPdf(settings, input.attachPdf)
 
       // One language for the PDF's labels and the mail around it.
-      const locale = await resolveCustomerLocale(organizationId, null)
+      const locale = await resolveDocumentLocale(organizationId)
       // The mail's own summary of the document quotes a figure, so it needs
       // the same currency the sheet prints in.
       const currencyCode = resolveCurrencyCode(settings['workshop.currencyCode'])
@@ -296,7 +296,7 @@ export async function sendInvoiceEmail(input: {
       const { record } = assembly
 
       // One language for the PDF's labels and the mail around it.
-      const locale = await resolveCustomerLocale(organizationId, null)
+      const locale = await resolveDocumentLocale(organizationId)
 
       let pdfBuffer: Buffer | null = null
       if (attachPdf) {
@@ -422,7 +422,7 @@ export async function sendInspectionEmail(input: {
       const attachPdf = resolveAttachPdf(settings, input.attachPdf)
 
       // One language for the certificate's labels and the mail around it.
-      const locale = await resolveCustomerLocale(organizationId, null)
+      const locale = await resolveDocumentLocale(organizationId)
 
       const vehicleName = `${inspection.vehicle.year} ${inspection.vehicle.make} ${inspection.vehicle.model}`
       const fileName = `Inspection-${vehicleName}.pdf`

+ 2 - 2
src/features/email/Lib/sendTemplatedMail.ts

@@ -1,6 +1,6 @@
 import 'server-only'
 
-import { resolveCustomerLocale } from '@/i18n/locale-from-request'
+import { resolveDocumentLocale } from '@/i18n/locale-from-request'
 import { db } from '@/lib/db'
 import { getOrgFromAddress, type SendMailOptions, sendOrgMail } from '@/lib/email'
 import { formatDate } from '@/lib/format'
@@ -95,7 +95,7 @@ export async function buildTemplatedMail<K extends EmailKind>(
   options: TemplatedMailOptions<K>
 ) {
   const [locale, settings] = await Promise.all([
-    options.locale ?? resolveCustomerLocale(organizationId, null),
+    options.locale ?? resolveDocumentLocale(organizationId),
     loadWorkshop(organizationId),
   ])
   const [template, messages] = await Promise.all([

+ 4 - 0
src/features/onboarding/Actions/createOnboardingOrg.ts

@@ -13,6 +13,7 @@ import {
 import { seedSampleData } from '../Lib/sampleData'
 import { CHECKLIST_DISMISSED_KEY, SAMPLE_DATA_IDS_KEY } from '../Lib/onboardingKeys'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
+import { saveInitialWorkshopLocale } from '@/features/settings/Lib/initialWorkshopLocale'
 import { safeTimeZone } from '@/lib/timezone'
 import type { ActionResult } from '@/lib/with-auth'
 import { organizationAllowance, SINGLE_WORKSHOP_MESSAGE } from '@/lib/features'
@@ -78,6 +79,9 @@ export async function createOnboardingOrg(
         })
       }
       const [locale, t] = await Promise.all([getLocale(), getTranslations('onboarding')])
+      // The workshop's language starts as the one its owner set it up in, so
+      // invoices and mails never fall back to English on their own.
+      await saveInitialWorkshopLocale(db, org.id, session.user.id, locale)
 
       const template = await installDefaultInspectionTemplates(org.id, locale)
       await installDefaultLaborPresets(org.id, session.user.id, t)

+ 23 - 0
src/features/settings/Lib/initialWorkshopLocale.ts

@@ -0,0 +1,23 @@
+import { type Locale, defaultLocale, locales } from '@/i18n/config'
+import type { db, TxClient } from '@/lib/db'
+import { SETTING_KEYS } from '../Schema/settingsSchema'
+
+/**
+ * Gives a new workshop its language: the one its creator is using when they
+ * set it up. Invoices, quotes and mails are printed in the workshop's
+ * language, so without one saved they had nothing to follow and came out in
+ * English.
+ */
+export async function saveInitialWorkshopLocale(
+  client: typeof db | TxClient,
+  organizationId: string,
+  userId: string,
+  locale: string
+) {
+  const value: Locale = locales.includes(locale as Locale) ? (locale as Locale) : defaultLocale
+  await client.appSetting.upsert({
+    where: { organizationId_key: { organizationId, key: SETTING_KEYS.WORKSHOP_LOCALE } },
+    create: { organizationId, key: SETTING_KEYS.WORKSHOP_LOCALE, value, userId },
+    update: {},
+  })
+}

+ 6 - 0
src/features/team/Actions/createNewOrganization.ts

@@ -1,6 +1,8 @@
 'use server'
 
 import { cookies } from 'next/headers'
+import { getLocale } from 'next-intl/server'
+import { saveInitialWorkshopLocale } from '@/features/settings/Lib/initialWorkshopLocale'
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import {
@@ -34,6 +36,9 @@ export async function createNewOrganization(input: unknown) {
         )
       }
 
+      // Invoices and mails print in the workshop's language, which starts as
+      // the one its owner is using now.
+      const locale = await getLocale()
       const org = await db.$transaction(async (tx) => {
         const created = await tx.organization.create({
           data: { name: data.name },
@@ -46,6 +51,7 @@ export async function createNewOrganization(input: unknown) {
             role: 'owner',
           },
         })
+        await saveInitialWorkshopLocale(tx, created.id, userId, locale)
 
         return created
       })

+ 6 - 0
src/features/team/Actions/teamActions.ts

@@ -1,6 +1,8 @@
 'use server'
 
 import { cookies } from 'next/headers'
+import { getLocale } from 'next-intl/server'
+import { saveInitialWorkshopLocale } from '@/features/settings/Lib/initialWorkshopLocale'
 import { db } from '@/lib/db'
 import { withAuth } from '@/lib/with-auth'
 import {
@@ -98,6 +100,9 @@ export async function createOrganization(input: unknown) {
         )
       }
 
+      // Invoices and mails print in the workshop's language, which starts as
+      // the one its owner is using now.
+      const locale = await getLocale()
       const org = await db.$transaction(async (tx) => {
         const created = await tx.organization.create({
           data: { name: data.name },
@@ -110,6 +115,7 @@ export async function createOrganization(input: unknown) {
             role: 'owner',
           },
         })
+        await saveInitialWorkshopLocale(tx, created.id, userId, locale)
 
         return created
       })

+ 58 - 19
src/i18n/locale-from-request.ts

@@ -1,3 +1,4 @@
+import { cookies, headers } from 'next/headers'
 import { db } from '@/lib/db'
 import { SETTING_KEYS } from '@/features/settings/Schema/settingsSchema'
 import { type Locale, defaultLocale, locales } from './config'
@@ -49,13 +50,51 @@ export function getBestLocaleFromHeader(acceptLanguage: string | null): Locale |
   return undefined
 }
 
+function isLocale(value: string | null | undefined): value is Locale {
+  return !!value && locales.includes(value as Locale)
+}
+
+/** The workshop's saved language, and whether customer pages must use it. */
+async function workshopLocaleSettings(
+  organizationId: string
+): Promise<{ forced: boolean; saved: Locale | undefined }> {
+  const settings = await db.appSetting.findMany({
+    where: {
+      organizationId,
+      key: { in: [SETTING_KEYS.FORCE_CUSTOMER_LOCALE, SETTING_KEYS.WORKSHOP_LOCALE] },
+    },
+    select: { key: true, value: true },
+  })
+  const saved = settings.find((s) => s.key === SETTING_KEYS.WORKSHOP_LOCALE)?.value
+  return {
+    forced: settings.find((s) => s.key === SETTING_KEYS.FORCE_CUSTOMER_LOCALE)?.value === 'true',
+    saved: isLocale(saved) ? saved : undefined,
+  }
+}
+
+/**
+ * The language the person making this request reads the app in: their own
+ * choice from the user menu, else their browser's. The same order the app's
+ * screens use. Undefined outside a request, such as in a scheduled job.
+ */
+async function requesterLocale(): Promise<Locale | undefined> {
+  try {
+    const [cookieStore, headerStore] = await Promise.all([cookies(), headers()])
+    const chosen = cookieStore.get('locale')?.value
+    if (isLocale(chosen)) return chosen
+    return getBestLocaleFromHeader(headerStore.get('accept-language'))
+  } catch {
+    return undefined
+  }
+}
+
 /**
- * Resolves the locale to use for customer-facing share PDFs.
+ * Resolves the locale to use for customer-facing pages and the PDFs on them.
  *
  * Order:
  *   1. If the workshop has `workshop.forceCustomerLocale = true`, use `workshop.locale`.
  *   2. Otherwise, the customer's browser `Accept-Language` header.
- *   3. Otherwise, the default locale.
+ *   3. Otherwise, the workshop's language, then the default locale.
  *
  * No cookie is read — these routes are downloaded by customers who won't have
  * a workshop-scoped `locale` cookie.
@@ -64,22 +103,22 @@ export async function resolveCustomerLocale(
   organizationId: string,
   acceptLanguageHeader: string | null
 ): Promise<Locale> {
-  const settings = await db.appSetting.findMany({
-    where: {
-      organizationId,
-      key: { in: [SETTING_KEYS.FORCE_CUSTOMER_LOCALE, SETTING_KEYS.WORKSHOP_LOCALE] },
-    },
-    select: { key: true, value: true },
-  })
-
-  const forced =
-    settings.find((s) => s.key === SETTING_KEYS.FORCE_CUSTOMER_LOCALE)?.value === 'true'
-  if (forced) {
-    const workshopLocale = settings.find((s) => s.key === SETTING_KEYS.WORKSHOP_LOCALE)?.value
-    if (workshopLocale && locales.includes(workshopLocale as Locale)) {
-      return workshopLocale as Locale
-    }
-  }
+  const { forced, saved } = await workshopLocaleSettings(organizationId)
+  if (forced && saved) return saved
+  return getBestLocaleFromHeader(acceptLanguageHeader) ?? saved ?? defaultLocale
+}
 
-  return getBestLocaleFromHeader(acceptLanguageHeader) ?? defaultLocale
+/**
+ * The language of a document the workshop makes for a customer: an invoice,
+ * quote, work order or inspection downloaded in the app or sent by mail.
+ *
+ * Always the workshop's language, whatever language the person making it
+ * reads the app in, so the user menu's language picker changes screens and
+ * never what a customer receives. A workshop with no saved language (it was
+ * only written from Settings → Localization before workshops got one at
+ * creation) gets the language of whoever makes the document, then English.
+ */
+export async function resolveDocumentLocale(organizationId: string): Promise<Locale> {
+  const { saved } = await workshopLocaleSettings(organizationId)
+  return saved ?? (await requesterLocale()) ?? defaultLocale
 }