Files
workspace/apps/go-crm/internal/handlers/clients.go
gabspereira 744868caa1 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
2026-05-23 16:55:55 -03:00

240 lines
7.7 KiB
Go

package handlers
import (
"net/http"
"strconv"
"time"
"go-crm/internal/db"
"github.com/go-chi/chi/v5"
)
func (a *App) ListClients(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
if limit == 0 {
limit = 20
}
clients, err := db.ListClients(a.DB, accountID, limit, offset)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(`<!DOCTYPE html>
<html>
<head>
<title>Clients</title>
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<style>
.edit-form { display:none; margin-top:0.5rem; padding:0.5rem; border:1px solid #ccc; }
.edit-row { display:none; }
</style>
</head>
<body>
<h1>Clients</h1>
<button class="btn" onclick="document.getElementById('clientForm').style.display='block'">Add Client</button>
<div id="clientForm" style="display:none; margin-top:1rem;">
<form hx-post="/clients" hx-target="#clientList" hx-swap="innerHTML">
<input type="text" name="name" placeholder="Name" required>
<input type="tel" name="phone" placeholder="Phone">
<input type="email" name="email" placeholder="Email">
<input type="text" name="address" placeholder="Address">
<textarea name="notes" placeholder="Notes"></textarea>
<button type="submit">Add Client</button>
</form>
</div>
<table>
<thead>
<tr><th>Name</th><th>Phone</th><th>WhatsApp</th><th>Actions</th></tr>
</thead>
<tbody id="clientList">
`))
for _, c := range clients {
var whatsappCell string
connected := false
if c.WhatsAppNumber != "" && a.WAConnector != nil {
connected, _ = a.WAConnector.IsConnected(r.Context(), c.ClientID)
}
if connected {
whatsappCell = c.WhatsAppNumber + ` <span style="color:#28a745">&#9679;</span>`
} else if c.WhatsAppConnected == 1 && c.WhatsAppNumber != "" {
whatsappCell = c.WhatsAppNumber + ` <span style="color:#dc3545">&#9675;</span> <a href="/leads/connect?client_id=` + strconv.FormatInt(c.ClientID, 10) + `">Reconnect</a>`
} else {
whatsappCell = `<a href="/leads/connect?client_id=` + strconv.FormatInt(c.ClientID, 10) + `">Connect</a>`
}
w.Write([]byte(`<tr>
<td>` + c.Name + `</td>
<td>` + c.Phone + `</td>
<td>` + whatsappCell + `</td>
<td>
<a href="/clients/` + strconv.FormatInt(c.ClientID, 10) + `">View</a>
<button type="button" onclick="document.getElementById('editForm` + strconv.FormatInt(c.ClientID, 10) + `').style.display='block'">Edit</button>
<form method="DELETE" style="display:inline" hx-delete="/clients/` + strconv.FormatInt(c.ClientID, 10) + `" hx-target="closest tr">
<button type="submit">Delete</button>
</form>
<tr id="editForm` + strconv.FormatInt(c.ClientID, 10) + `" class="edit-row"><td colspan="4">
<form hx-put="/clients/` + strconv.FormatInt(c.ClientID, 10) + `" hx-target="#clientList" hx-swap="innerHTML">
<input type="text" name="name" value="` + c.Name + `">
<input type="tel" name="phone" value="` + c.Phone + `">
<input type="email" name="email" value="` + c.Email + `">
<input type="text" name="address" value="` + c.Address + `">
<textarea name="notes">` + c.Notes + `</textarea>
<button type="submit">Save</button>
</form>
</td></tr>
</td>
</tr>`))
}
w.Write([]byte(`</tbody></table>
<p><a href="/">Back to Home</a></p>
</body></html>`))
}
func (a *App) CreateClient(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
r.ParseForm()
client := db.Client{
AccountID: accountID,
Name: r.FormValue("name"),
Phone: r.FormValue("phone"),
Email: r.FormValue("email"),
Address: r.FormValue("address"),
Notes: r.FormValue("notes"),
CreatedAt: time.Now().Unix(),
}
if err := client.Create(a.DB); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("HX-Refresh", "true")
}
func (a *App) ViewClient(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
client, err := db.GetClientByID(a.DB, accountID, id)
if err != nil {
http.Error(w, "Client not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
var whatsappSection string
if client.WhatsAppNumber != "" {
connected := false
if a.WAConnector != nil {
connected, _ = a.WAConnector.IsConnected(r.Context(), client.ClientID)
}
status := "Not connected"
style := "color:#999"
if connected {
status = "Connected"
style = "color:#28a745"
} else if client.WhatsAppConnected == 1 {
status = "Disconnected (was connected)"
style = "color:#dc3545"
}
whatsappSection = `
<p>WhatsApp: <strong>` + client.WhatsAppNumber + `</strong> <span style="` + style + `">(` + status + `)</span></p>
<p><a href="/leads/connect?client_id=` + strconv.FormatInt(client.ClientID, 10) + `">Reconnect WhatsApp</a></p>`
} else {
whatsappSection = `
<p>WhatsApp: Not configured <a href="/leads/connect?client_id=` + strconv.FormatInt(client.ClientID, 10) + `">Connect</a></p>`
}
w.Write([]byte(`<!DOCTYPE html>
<html>
<head></head>
<body>
<h1>` + client.Name + `</h1>
<p>Client ID: ` + strconv.FormatInt(client.ClientID, 10) + `</p>
<p>Phone: ` + client.Phone + `</p>
<p>Email: ` + client.Email + `</p>
<p>Address: ` + client.Address + `</p>
<p>Notes: ` + client.Notes + `</p>` + whatsappSection + `
<a href="/clients">Back</a>
</body></html>`))
}
func (a *App) UpdateClient(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
r.ParseForm()
client := db.Client{
ClientID: id,
AccountID: accountID,
Name: r.FormValue("name"),
Phone: r.FormValue("phone"),
Email: r.FormValue("email"),
Address: r.FormValue("address"),
Notes: r.FormValue("notes"),
}
if err := client.Update(a.DB); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("HX-Refresh", "true")
}
func (a *App) DeleteClient(w http.ResponseWriter, r *http.Request) {
accountID, ok := a.requireAuth(w, r)
if !ok {
return
}
id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
client := &db.Client{ClientID: id, AccountID: accountID}
if err := client.Delete(a.DB); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte("OK"))
}
// --- package-level shims kept for existing tests ---
func ListClients(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).ListClients(w, r)
}
func CreateClient(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).CreateClient(w, r)
}
func ViewClient(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).ViewClient(w, r)
}
func UpdateClient(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).UpdateClient(w, r)
}
func DeleteClient(w http.ResponseWriter, r *http.Request) {
(&App{DB: DB, WAConnector: WAConnector}).DeleteClient(w, r)
}