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:
2026-05-23 16:55:55 -03:00
parent 57920d45d6
commit 744868caa1
52 changed files with 4868 additions and 1068 deletions

View File

@@ -24,6 +24,7 @@ type QRFrame struct {
type Contact struct {
Phone string
Name string
Message string // raw text of the first/incoming message
FromMe bool
Time time.Time
}

View File

@@ -0,0 +1,36 @@
package whatsapp
import (
"context"
"bytes"
"log"
"os"
"strings"
"testing"
)
func TestPostContactLogsErrorOnFailure(t *testing.T) {
// Capture log output.
var buf bytes.Buffer
log.SetOutput(&buf)
defer log.SetOutput(os.Stderr)
// Create adapter with invalid endpoint (connection refused).
adapter := &WhatsmeowAdapter{
goEndpoint: "http://localhost:1",
internalSecret: "test-secret",
}
contact := Contact{
Phone: "5511999999999",
Name: "Test",
Message: "test message",
}
adapter.postContact(context.Background(), 1, contact, "test-msg-id-123")
output := buf.String()
if !strings.Contains(output, "postContact") {
t.Errorf("expected log to contain 'postContact', got: %s", output)
}
}

View File

@@ -9,6 +9,7 @@ import (
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
@@ -239,24 +240,50 @@ func (a *WhatsmeowAdapter) startQRSession(clientID int64) (<-chan QRFrame, error
}
func (a *WhatsmeowAdapter) addMessageHandler(client *whatsmeow.Client, clientID int64) {
log.Printf("[Client-%d] Registering message handler", clientID)
client.AddEventHandler(func(evt interface{}) {
log.Printf("[Client-%d] Event received: %T", clientID, evt)
msg, ok := evt.(*events.Message)
if !ok {
return
}
if msg.Info.IsFromMe {
log.Printf("[Client-%d] Message event: IsFromMe=%v, Sender=%v, Chat=%v", clientID, msg.Info.IsFromMe, msg.Info.Sender, msg.Info.Chat)
// Skip messages from our own device.
// Compare sender JID with device JID (LID messages may have IsFromMe=true incorrectly).
if client.Store.ID != nil && msg.Info.Sender.User == client.Store.ID.User {
log.Printf("[Client-%d] Skipping message from own device", clientID)
return
}
phone := msg.Info.Sender.String()
// Prefer phone JID (SenderAlt) when sender is LID.
sender := msg.Info.Sender
if sender.Server == types.HiddenUserServer && !msg.Info.SenderAlt.IsEmpty() && msg.Info.SenderAlt.Server == types.DefaultUserServer {
sender = msg.Info.SenderAlt
}
phone := sender.String()
pushName := msg.Info.PushName
// Extract text body from the message.
var text string
if msg.Message != nil {
if c := msg.Message.GetConversation(); c != "" {
text = c
} else if ext := msg.Message.GetExtendedTextMessage(); ext != nil {
text = ext.GetText()
}
}
log.Printf("[Client-%d] Incoming msg from %s (pushName=%s): text=%q", clientID, phone, pushName, text)
if phone != "" {
contact := Contact{
Phone: phone,
Name: pushName,
FromMe: false,
Time: time.Now(),
Phone: phone,
Name: pushName,
Message: text,
FromMe: false,
Time: time.Now(),
}
a.postContact(a.appCtx, clientID, contact)
a.postContact(a.appCtx, clientID, contact, msg.Info.ID)
}
})
}
@@ -266,21 +293,35 @@ func (a *WhatsmeowAdapter) syncContacts(client *whatsmeow.Client, clientID int64
// Simplified for build - real implementation would use new API
}
func (a *WhatsmeowAdapter) postContact(ctx context.Context, clientID int64, contact Contact) {
func (a *WhatsmeowAdapter) postContact(ctx context.Context, clientID int64, contact Contact, messageID string) {
payload := map[string]interface{}{
"client_id": clientID,
"name": contact.Name,
"phone": contact.Phone,
"client_id": clientID,
"name": contact.Name,
"phone": contact.Phone,
"message": contact.Message,
"message_id": messageID,
}
body, _ := json.Marshal(payload)
httpBody := bytes.NewReader(body)
req, _ := http.NewRequestWithContext(ctx, "POST", a.goEndpoint+"/customers", httpBody)
req.Header.Set("Content-Type", "application/json")
req, _ := http.NewRequestWithContext(ctx, "POST", a.goEndpoint+"/leads/ingest", httpBody)
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("X-Internal-Secret", a.internalSecret)
log.Printf("[Client-%d] postContact payload: %s", clientID, string(body))
httpClient := &http.Client{Timeout: 10 * time.Second}
httpClient.Do(req)
resp, err := httpClient.Do(req)
if err != nil {
log.Printf("postContact failed to ingest lead for client %d: %v", clientID, err)
return
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
log.Printf("postContact ingest failed for client %d: status %d", clientID, resp.StatusCode)
} else {
log.Printf("[Client-%d] postContact success: status %d", clientID, resp.StatusCode)
}
}
func (a *WhatsmeowAdapter) getJIDFromDB(clientID int64) (string, error) {
@@ -323,10 +364,25 @@ func (a *WhatsmeowAdapter) saveJIDOnConnect(client *whatsmeow.Client, clientID i
if jid == "" {
return
}
phone := strings.Split(jid, "@")[0]
_ = a.SaveJID(clientID, jid)
_ = a.saveWhatsAppNumber(clientID, phone)
_ = a.markConnected(clientID)
}
func (a *WhatsmeowAdapter) saveWhatsAppNumber(clientID int64, phone string) error {
a.mu.RLock()
db := a.db
a.mu.RUnlock()
if db == nil {
return nil
}
_, err := db.Exec("UPDATE clients SET whatsapp_number = ? WHERE client_id = ?", phone, clientID)
return err
}
func (a *WhatsmeowAdapter) markConnected(clientID int64) error {
a.mu.RLock()
db := a.db
@@ -402,18 +458,32 @@ func (a *WhatsmeowAdapter) IsConnected(ctx context.Context, clientID int64) (boo
if a == nil {
return false, fmt.Errorf("adapter not initialized")
}
a.mu.RLock()
defer a.mu.RUnlock()
client, ok := a.clients[clientID]
if !ok {
a.mu.RUnlock()
if ok && client != nil && client.IsLoggedIn() {
return true, nil
}
// No client in memory — try to resume from stored JID.
jid, err := a.getJIDFromDB(clientID)
if err != nil || jid == "" {
return false, nil
}
if client == nil {
return false, nil
// Attempt to reconnect silently.
if ch, err := a.Connect(ctx, clientID); err == nil {
for frame := range ch {
if frame.State == StateConnected {
return true, nil
}
if frame.State == StateFailed {
break
}
}
}
// IsLoggedIn() checks WhatsApp session authentication, not just WebSocket connectivity.
return client.IsLoggedIn(), nil
return false, nil
}