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:
@@ -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
|
||||
}
|
||||
|
||||
36
apps/go-crm/internal/whatsapp/postcontact_test.go
Normal file
36
apps/go-crm/internal/whatsapp/postcontact_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user