feat(go-crm): full auth, routing, middleware, and supporting infra
- Add auth handlers (signup, login, logout, account management) with bcrypt - Add client, customer, service, scheduling, payment, question, answer handlers - Add dashboard, monthly report, and lead pipeline pages - Add UTF-8 middleware to force charset on HTML responses - Add config package with env-based overrides for DB path, secrets, endpoints - Add parser package for WhatsApp message ingestion - Add clean-arch layers: pkg/domain, pkg/repo, pkg/usecase for leads - Add cmd/migrate utility for DB migrations - Add Makefile, README, run-tests.sh, and dev scripts - Update docker-compose.yml with memory limits - Update .air.toml to exclude DB files and stop on errors - Update whatsapp-sync dependencies and add src/index.js entrypoint - Add whatsme standalone WhatsApp reader app (source only) - Untrack .opencode-sandbox/data/go-crm.db from git history - Expand root .gitignore: ngrok, tmp dirs, sandbox DBs, compiled binaries
This commit is contained in:
73
apps/go-crm/internal/parser/parser.go
Normal file
73
apps/go-crm/internal/parser/parser.go
Normal file
@@ -0,0 +1,73 @@
|
||||
// Package parser provides message text parsing utilities for the CRM.
|
||||
// It extracts structured lead information from raw WhatsApp message text.
|
||||
package parser
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// ExtractService scans msg for any keyword in the mapping (case-insensitive,
|
||||
// accent-insensitive) and returns the corresponding service name.
|
||||
// If no keyword matches, it returns "Não especificou".
|
||||
//
|
||||
// mapping is a map[keyword]serviceName loaded from the service_keywords table.
|
||||
func ExtractService(msg string, mapping map[string]string) string {
|
||||
normalized := normalizeText(msg)
|
||||
|
||||
// Sort by keyword length descending so longer phrases match before shorter ones.
|
||||
// We iterate the map which has no order, so we do two passes:
|
||||
// first collect all matches, then pick the longest keyword match.
|
||||
type match struct {
|
||||
keyword string
|
||||
service string
|
||||
}
|
||||
var best match
|
||||
|
||||
for kw, svc := range mapping {
|
||||
normKW := normalizeText(kw)
|
||||
if strings.Contains(normalized, normKW) {
|
||||
if len(kw) > len(best.keyword) {
|
||||
best = match{keyword: kw, service: svc}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if best.service != "" {
|
||||
return best.service
|
||||
}
|
||||
return "Não especificou"
|
||||
}
|
||||
|
||||
// NormalizePhone converts a raw WhatsApp phone string to E.164 format (+countrycode...).
|
||||
// It strips the @s.whatsapp.net suffix, removes non-digit characters, and prepends "+".
|
||||
func NormalizePhone(raw string) string {
|
||||
// Strip WhatsApp JID suffix.
|
||||
if idx := strings.Index(raw, "@"); idx != -1 {
|
||||
raw = raw[:idx]
|
||||
}
|
||||
// Keep only digits.
|
||||
digits := regexp.MustCompile(`\D`).ReplaceAllString(raw, "")
|
||||
if digits == "" {
|
||||
return raw
|
||||
}
|
||||
return "+" + digits
|
||||
}
|
||||
|
||||
// normalizeText lowercases and removes accents from s for fuzzy comparison.
|
||||
func normalizeText(s string) string {
|
||||
// NFD decomposition splits accented characters into base + combining marks.
|
||||
t := norm.NFD.String(strings.ToLower(s))
|
||||
// Remove combining marks (Unicode category Mn).
|
||||
var b strings.Builder
|
||||
for _, r := range t {
|
||||
if unicode.Is(unicode.Mn, r) {
|
||||
continue
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
117
apps/go-crm/internal/parser/parser_test.go
Normal file
117
apps/go-crm/internal/parser/parser_test.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package parser_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go-crm/internal/parser"
|
||||
)
|
||||
|
||||
func TestExtractService_KnownKeywords(t *testing.T) {
|
||||
keywords := map[string]string{
|
||||
"head spa": "Head Spa",
|
||||
"head-spa": "Head Spa",
|
||||
"headspa": "Head Spa",
|
||||
"massagem": "Massagem completa",
|
||||
"massagem completa": "Massagem completa",
|
||||
"drenagem": "Drenagem linfatica",
|
||||
"linfática": "Drenagem linfatica",
|
||||
"hydra": "Hydra Boost",
|
||||
"hydra boost": "Hydra Boost",
|
||||
"henna": "Design Henna",
|
||||
"design henna": "Design Henna",
|
||||
"masculino": "Masculino",
|
||||
"masc": "Masculino",
|
||||
}
|
||||
|
||||
mapping := map[string]string{
|
||||
"head spa": "Head Spa",
|
||||
"head-spa": "Head Spa",
|
||||
"headspa": "Head Spa",
|
||||
"massagem": "Massagem completa",
|
||||
"massagem completa": "Massagem completa",
|
||||
"drenagem": "Drenagem linfatica",
|
||||
"linfática": "Drenagem linfatica",
|
||||
"hydra": "Hydra Boost",
|
||||
"hydra boost": "Hydra Boost",
|
||||
"henna": "Design Henna",
|
||||
"design henna": "Design Henna",
|
||||
"masculino": "Masculino",
|
||||
"masc": "Masculino",
|
||||
}
|
||||
|
||||
for kw, expected := range keywords {
|
||||
// Build messages in Portuguese with the keyword embedded in natural phrasing.
|
||||
messages := []string{
|
||||
"Olá, gostaria de agendar um " + kw,
|
||||
"Boa tarde! Quero fazer " + kw + " por favor",
|
||||
"Quanto custa " + kw + "?",
|
||||
kw,
|
||||
}
|
||||
for _, msg := range messages {
|
||||
got := parser.ExtractService(msg, mapping)
|
||||
if got != expected {
|
||||
t.Errorf("message %q with keyword %q: got %q, want %q", msg, kw, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractService_UnknownMessage_ReturnsNaoEspecificou(t *testing.T) {
|
||||
mapping := map[string]string{
|
||||
"massagem": "Massagem completa",
|
||||
}
|
||||
|
||||
messages := []string{
|
||||
"Olá, tudo bem?",
|
||||
"Qual o horário de funcionamento?",
|
||||
"Vocês atendem no sábado?",
|
||||
"",
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
got := parser.ExtractService(msg, mapping)
|
||||
if got != "Não especificou" {
|
||||
t.Errorf("message %q: got %q, want %q", msg, got, "Não especificou")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractService_CaseInsensitive(t *testing.T) {
|
||||
mapping := map[string]string{
|
||||
"head spa": "Head Spa",
|
||||
"henna": "Design Henna",
|
||||
}
|
||||
|
||||
cases := map[string]string{
|
||||
"Quero HEAD SPA": "Head Spa",
|
||||
"HENNA por favor": "Design Henna",
|
||||
"Head Spa agora": "Head Spa",
|
||||
}
|
||||
|
||||
for msg, expected := range cases {
|
||||
got := parser.ExtractService(msg, mapping)
|
||||
if got != expected {
|
||||
t.Errorf("message %q: got %q, want %q", msg, got, expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePhone(t *testing.T) {
|
||||
cases := []struct {
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{"5511999999999@s.whatsapp.net", "+5511999999999"},
|
||||
{"5511999999999", "+5511999999999"},
|
||||
{"+5511999999999", "+5511999999999"},
|
||||
{"55 11 99999-9999", "+5511999999999"},
|
||||
{"11999999999", "+11999999999"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
got := parser.NormalizePhone(c.raw)
|
||||
if got != c.want {
|
||||
t.Errorf("NormalizePhone(%q) = %q, want %q", c.raw, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user