fix: global document and phone handling — remove Brazil-specific formatting

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 <noreply@anthropic.com>
This commit is contained in:
Tiago Yamamoto 2026-02-22 12:46:54 -06:00
parent fd085ec193
commit 3a26af3df5
3 changed files with 54 additions and 33 deletions

View file

@ -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

View file

@ -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
}

View file

@ -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;
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;
// 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 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 530 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,