From 3a26af3df5f12a3d81f241dbd842b18831234b3b Mon Sep 17 00:00:00 2001 From: Tiago Yamamoto Date: Sun, 22 Feb 2026 12:46:54 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20global=20document=20and=20phone=20handli?= =?UTF-8?q?ng=20=E2=80=94=20remove=20Brazil-specific=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend (jobs/new): - Replace isValidCNPJ (checksum algorithm) with isValidDocument: accepts any tax document with 5–30 alphanumeric chars (CNPJ, EIN, VAT, etc.) - Add cleanPhone(): strips formatting chars (dashes, spaces, parens) and keeps only digits + optional leading '+'; replaces cleanDigits+prepend - Phone sent as '+5511999998888' if user typed '+55...', or '11999998888' if no country code was provided — no '+' blindly prepended anymore - Company document sent stripped of all non-alphanumeric before API call - Update label placeholder from '00.000.000/0000-00' to 'CNPJ, EIN, VAT...' - Rename error key invalidCnpj → invalidDocument in all 3 locales (pt, en, es) Backend (create_company use case): - Add SanitizePhone() to utils/sanitizer.go: strips all non-digit chars except a leading '+'; '(11) 99999-8888' → '11999998888' - Apply SanitizePhone to input.Phone before persisting to DB Co-Authored-By: Claude Sonnet 4.6 --- .../core/usecases/tenant/create_company.go | 5 +- backend/internal/utils/sanitizer.go | 22 +++++++ frontend/src/app/jobs/new/page.tsx | 60 +++++++++---------- 3 files changed, 54 insertions(+), 33 deletions(-) diff --git a/backend/internal/core/usecases/tenant/create_company.go b/backend/internal/core/usecases/tenant/create_company.go index 89d575a..21846ba 100644 --- a/backend/internal/core/usecases/tenant/create_company.go +++ b/backend/internal/core/usecases/tenant/create_company.go @@ -71,7 +71,10 @@ func (uc *CreateCompanyUseCase) Execute(ctx context.Context, input dto.CreateCom // Map optional fields if input.Phone != "" { - company.Phone = &input.Phone + clean := utils.SanitizePhone(input.Phone) + if clean != "" { + company.Phone = &clean + } } if input.Website != nil { company.Website = input.Website diff --git a/backend/internal/utils/sanitizer.go b/backend/internal/utils/sanitizer.go index ae70bcf..7977aa5 100644 --- a/backend/internal/utils/sanitizer.go +++ b/backend/internal/utils/sanitizer.go @@ -87,3 +87,25 @@ func StripHTML(input string) string { reg := regexp.MustCompile(`<[^>]*>`) return reg.ReplaceAllString(input, "") } + +// SanitizePhone cleans a phone number, keeping only digits and an optional +// leading '+' (international dialing prefix). Removes all formatting characters +// such as spaces, dashes, parentheses, and dots. +// +// Examples: +// +// "+55 (11) 99999-8888" → "+5511999998888" +// "(11) 99999-8888" → "11999998888" +// "+1-800-555-0100" → "+18005550100" +func SanitizePhone(phone string) string { + phone = strings.TrimSpace(phone) + if phone == "" { + return "" + } + hasPlus := strings.HasPrefix(phone, "+") + digitsOnly := regexp.MustCompile(`\D`).ReplaceAllString(phone, "") + if hasPlus { + return "+" + digitsOnly + } + return digitsOnly +} diff --git a/frontend/src/app/jobs/new/page.tsx b/frontend/src/app/jobs/new/page.tsx index 13a8f3e..fa6dc03 100644 --- a/frontend/src/app/jobs/new/page.tsx +++ b/frontend/src/app/jobs/new/page.tsx @@ -39,25 +39,21 @@ type ApiCountry = { const cleanDigits = (value: string) => value.replace(/\D/g, ""); -const isValidCNPJ = (value: string) => { - const cnpj = cleanDigits(value); - if (!cnpj) return true; - if (cnpj.length !== 14) return false; - if (/^(\d)\1+$/.test(cnpj)) return false; +// Keeps only digits and a leading '+'. Strips all other formatting. +// "+55 (11) 99999-8888" → "+5511999998888" +// "(11) 99999-8888" → "11999998888" +const cleanPhone = (value: string): string => { + const v = value.trim(); + if (!v) return ""; + const hasPlus = v.startsWith("+"); + const digits = v.replace(/\D/g, ""); + return hasPlus ? `+${digits}` : digits; +}; - const calcCheckDigit = (base: string, weights: number[]) => { - const sum = base - .split("") - .reduce((acc, current, index) => acc + Number(current) * weights[index], 0); - const mod = sum % 11; - return mod < 2 ? 0 : 11 - mod; - }; - - const base12 = cnpj.slice(0, 12); - const digit1 = calcCheckDigit(base12, [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]); - const digit2 = calcCheckDigit(`${base12}${digit1}`, [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]); - - return cnpj === `${base12}${digit1}${digit2}`; +// Accepts any tax document with 5–30 alphanumeric characters (global). +const isValidDocument = (value: string): boolean => { + const clean = value.replace(/[^a-zA-Z0-9]/g, ""); + return clean.length >= 5 && clean.length <= 30; }; const isHttpsUrl = (value: string) => /^https:\/\/.+/i.test(value); @@ -125,8 +121,8 @@ const contentByLocale = { employeeCount: "Número de empregados", foundedYear: "Ano de fundação", foundedYearPlaceholder: "Ex: 2012", - cnpj: "CNPJ", - cnpjPlaceholder: "00.000.000/0000-00", + cnpj: "Documento fiscal", + cnpjPlaceholder: "CNPJ, EIN, VAT...", companyDescription: "Descrição da empresa", companyDescPlaceholder: "Convide os candidatos a conhecer a organização...", applicationChannel: "Como receber candidaturas", @@ -232,7 +228,7 @@ const contentByLocale = { invalidPhone: "Informe um telefone com DDI válido. Exemplo: +55 11999998888", salaryFixedRequired: "Informe o salário fixo.", salaryRangeRequired: "Informe a faixa salarial (mínimo e máximo).", - invalidCnpj: "CNPJ da empresa inválido.", + invalidDocument: "Documento da empresa inválido (mínimo 5 caracteres).", billingRequired: "Preencha documento, país, endereço e e-mail de faturamento.", billingEmailInvalid: "Informe um e-mail de faturamento válido.", termsRequired: "Você precisa aceitar as condições legais para continuar.", @@ -289,8 +285,8 @@ const contentByLocale = { employeeCount: "Number of employees", foundedYear: "Founded year", foundedYearPlaceholder: "e.g. 2012", - cnpj: "Tax ID", - cnpjPlaceholder: "00.000.000/0000-00", + cnpj: "Tax document", + cnpjPlaceholder: "CNPJ, EIN, VAT...", companyDescription: "Company description", companyDescPlaceholder: "Invite candidates to learn about the organization...", applicationChannel: "How to receive applications", @@ -396,7 +392,7 @@ const contentByLocale = { invalidPhone: "Please enter a valid phone with country code. Example: +1 5551234567", salaryFixedRequired: "Please enter the fixed salary.", salaryRangeRequired: "Please enter the salary range (min and max).", - invalidCnpj: "Invalid company tax ID.", + invalidDocument: "Invalid company tax document (minimum 5 characters).", billingRequired: "Please fill in document, country, address and billing email.", billingEmailInvalid: "Please enter a valid billing email.", termsRequired: "You must accept the legal terms to continue.", @@ -453,8 +449,8 @@ const contentByLocale = { employeeCount: "Número de empleados", foundedYear: "Año de fundación", foundedYearPlaceholder: "Ej: 2012", - cnpj: "ID Fiscal", - cnpjPlaceholder: "00.000.000/0000-00", + cnpj: "Documento fiscal", + cnpjPlaceholder: "CNPJ, EIN, VAT...", companyDescription: "Descripción de la empresa", companyDescPlaceholder: "Invite a los candidatos a conocer la organización...", applicationChannel: "Cómo recibir candidaturas", @@ -560,7 +556,7 @@ const contentByLocale = { invalidPhone: "Ingrese un teléfono con código de país válido. Ejemplo: +34 612345678", salaryFixedRequired: "Ingrese el salario fijo.", salaryRangeRequired: "Ingrese el rango salarial (mínimo y máximo).", - invalidCnpj: "ID fiscal de la empresa inválido.", + invalidDocument: "Documento fiscal de la empresa inválido (mínimo 5 caracteres).", billingRequired: "Complete documento, país, dirección y email de facturación.", billingEmailInvalid: "Ingrese un email de facturación válido.", termsRequired: "Debe aceptar las condiciones legales para continuar.", @@ -739,8 +735,8 @@ export default function PostJobPage() { toast.error(c.errors.salaryRangeRequired); return false; } - if (company.document && !isValidCNPJ(company.document)) { - toast.error(c.errors.invalidCnpj); + if (company.document && !isValidDocument(company.document)) { + toast.error(c.errors.invalidDocument); return false; } return true; @@ -796,7 +792,7 @@ export default function PostJobPage() { setLoading(true); try { const apiBase = process.env.NEXT_PUBLIC_API_URL || ""; - const billingPhone = cleanDigits(billing.contactMobile || billing.contactPhone || job.applicationPhone); + const billingPhone = cleanPhone(billing.contactMobile || billing.contactPhone || job.applicationPhone || ""); const contactFullName = [billing.contactName, billing.contactLastName].filter(Boolean).join(" "); const registerRes = await fetch(`${apiBase}/api/v1/auth/register/company`, { @@ -806,9 +802,9 @@ export default function PostJobPage() { companyName: company.name, email: billing.contactEmail, contact: contactFullName || null, - document: cleanDigits(company.document) || null, + document: company.document.replace(/[^a-zA-Z0-9]/g, "") || null, password: billing.password, - phone: billingPhone ? `+${billingPhone}` : null, + phone: billingPhone || null, website: company.website || null, employeeCount: company.employeeCount || null, foundedYear: company.foundedYear ? Number(company.foundedYear) : null,