feat(leads): add lead pipeline, keyword/status mapping, and encoding fixes
- Add lead ingestion, review queue, and keyword/status management - Add DB schema: leads, service_keywords, lead_statuses, processed_messages - Add migration with default keyword/status seeding per client - Fix SQLite read/write deadlock in sanitizeEncoding - Fix UTF-8 corruption: replace byte-iterating replaceAll with strings.ReplaceAll - Add utf8.ValidString guard to decodeLatin1 to avoid double-encoding - Remove hardcoded internal-secret; use config.InternalSecret() everywhere - Add .gitignore for binaries, SQLite DBs, build artifacts, WhatsApp sessions
This commit is contained in:
242
apps/go-crm/internal/handlers/lead_keywords.go
Normal file
242
apps/go-crm/internal/handlers/lead_keywords.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"go-crm/internal/db"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
// LeadKeywordsPage renders the keyword mapping management screen.
|
||||
// GET /leads/keywords
|
||||
func (a *App) LeadKeywordsPage(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
clientID := a.clientIDForAccount(accountID)
|
||||
if clientID == 0 {
|
||||
http.Error(w, "No client found for account", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
kws, err := db.ListServiceKeywords(a.DB, clientID)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
statuses, _ := db.ListLeadStatuses(a.DB, clientID)
|
||||
services := a.serviceNames(clientID)
|
||||
|
||||
pendingCount, _ := db.CountLeadsNeedingReview(a.DB, clientID)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprintf(w, `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Keyword Mapping</title>
|
||||
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||
<style>
|
||||
body { font-family: sans-serif; padding: 1rem; }
|
||||
table { border-collapse: collapse; width: 100%%; margin-bottom: 2rem; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
|
||||
th { background: #f5f5f5; }
|
||||
.badge { background: #dc3545; color: #fff; border-radius: 1rem; padding: 0.2rem 0.6rem; font-size: 0.8rem; }
|
||||
.section { margin-top: 2rem; }
|
||||
form.inline { display: inline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Keyword Mapping</h1>
|
||||
<nav>
|
||||
<a href="/">Home</a> |
|
||||
<a href="/leads/review">Review Queue <span class="badge">%d</span></a> |
|
||||
<a href="/leads/all">All Leads</a> |
|
||||
<a href="/report">Monthly Report</a>
|
||||
</nav>
|
||||
<br>
|
||||
|
||||
<h2>Service Keywords</h2>
|
||||
<p>Add keywords that trigger automatic service detection. Case and accent insensitive.</p>
|
||||
<form hx-post="/leads/keywords" hx-target="#keywordTable" hx-swap="outerHTML">
|
||||
<select name="service_name">%s</select>
|
||||
<input type="text" name="keyword" placeholder="Keyword (e.g. massagem)" required>
|
||||
<button type="submit">Add Keyword</button>
|
||||
</form>
|
||||
<br><br>
|
||||
%s
|
||||
|
||||
<div class="section">
|
||||
<h2>Lead Statuses</h2>
|
||||
<p>Manage the status options available for leads.</p>
|
||||
<form hx-post="/leads/statuses" hx-target="#statusTable" hx-swap="outerHTML">
|
||||
<input type="text" name="status_name" placeholder="New status name" required>
|
||||
<button type="submit">Add Status</button>
|
||||
</form>
|
||||
<br><br>
|
||||
%s
|
||||
</div>
|
||||
</body></html>`,
|
||||
pendingCount,
|
||||
buildServiceSelectOptions(services),
|
||||
renderKeywordTable(kws),
|
||||
renderStatusTable(statuses),
|
||||
)
|
||||
}
|
||||
|
||||
// AddServiceKeyword adds a new keyword mapping.
|
||||
// POST /leads/keywords
|
||||
func (a *App) AddServiceKeyword(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
clientID := a.clientIDForAccount(accountID)
|
||||
r.ParseForm()
|
||||
svc := r.FormValue("service_name")
|
||||
kw := r.FormValue("keyword")
|
||||
if svc == "" || kw == "" {
|
||||
http.Error(w, "service_name and keyword required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
db.AddServiceKeyword(a.DB, clientID, svc, kw)
|
||||
|
||||
kws, _ := db.ListServiceKeywords(a.DB, clientID)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, renderKeywordTable(kws))
|
||||
}
|
||||
|
||||
// DeleteServiceKeywordHandler removes a keyword mapping.
|
||||
// DELETE /leads/keywords/{id}
|
||||
func (a *App) DeleteServiceKeywordHandler(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
clientID := a.clientIDForAccount(accountID)
|
||||
kwID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
db.DeleteServiceKeyword(a.DB, clientID, kwID)
|
||||
|
||||
kws, _ := db.ListServiceKeywords(a.DB, clientID)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, renderKeywordTable(kws))
|
||||
}
|
||||
|
||||
// AddLeadStatusHandler adds a new custom lead status.
|
||||
// POST /leads/statuses
|
||||
func (a *App) AddLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
clientID := a.clientIDForAccount(accountID)
|
||||
r.ParseForm()
|
||||
statusName := r.FormValue("status_name")
|
||||
if statusName == "" {
|
||||
http.Error(w, "status_name required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
db.AddLeadStatus(a.DB, clientID, statusName)
|
||||
|
||||
statuses, _ := db.ListLeadStatuses(a.DB, clientID)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, renderStatusTable(statuses))
|
||||
}
|
||||
|
||||
// DeleteLeadStatusHandler removes a lead status.
|
||||
// DELETE /leads/statuses/{id}
|
||||
func (a *App) DeleteLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
clientID := a.clientIDForAccount(accountID)
|
||||
statusID, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
|
||||
db.DeleteLeadStatus(a.DB, clientID, statusID)
|
||||
|
||||
statuses, _ := db.ListLeadStatuses(a.DB, clientID)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
fmt.Fprint(w, renderStatusTable(statuses))
|
||||
}
|
||||
|
||||
// --- rendering helpers -------------------------------------------------------
|
||||
|
||||
func buildServiceSelectOptions(services []string) string {
|
||||
out := ""
|
||||
for _, s := range services {
|
||||
if s == "Não especificou" {
|
||||
continue // don't map keywords to the fallback
|
||||
}
|
||||
out += fmt.Sprintf(`<option value="%s">%s</option>`, htmlEscape(s), htmlEscape(s))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func renderKeywordTable(kws []db.ServiceKeyword) string {
|
||||
out := `<table id="keywordTable">
|
||||
<thead><tr><th>Service</th><th>Keyword</th><th>Actions</th></tr></thead>
|
||||
<tbody>`
|
||||
if len(kws) == 0 {
|
||||
out += `<tr><td colspan="3">No keywords defined.</td></tr>`
|
||||
}
|
||||
for _, kw := range kws {
|
||||
out += fmt.Sprintf(`
|
||||
<tr>
|
||||
<td>%s</td>
|
||||
<td>%s</td>
|
||||
<td>
|
||||
<form hx-delete="/leads/keywords/%d" hx-target="#keywordTable" hx-swap="outerHTML" style="display:inline">
|
||||
<button type="submit">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>`, htmlEscape(kw.ServiceName), htmlEscape(kw.Keyword), kw.KeywordID)
|
||||
}
|
||||
out += `</tbody></table>`
|
||||
return out
|
||||
}
|
||||
|
||||
func renderStatusTable(statuses []db.LeadStatus) string {
|
||||
out := `<table id="statusTable">
|
||||
<thead><tr><th>Status</th><th>Actions</th></tr></thead>
|
||||
<tbody>`
|
||||
if len(statuses) == 0 {
|
||||
out += `<tr><td colspan="2">No statuses defined.</td></tr>`
|
||||
}
|
||||
for _, s := range statuses {
|
||||
out += fmt.Sprintf(`
|
||||
<tr>
|
||||
<td>%s</td>
|
||||
<td>
|
||||
<form hx-delete="/leads/statuses/%d" hx-target="#statusTable" hx-swap="outerHTML" style="display:inline">
|
||||
<button type="submit">Remove</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>`, htmlEscape(s.StatusName), s.StatusID)
|
||||
}
|
||||
out += `</tbody></table>`
|
||||
return out
|
||||
}
|
||||
|
||||
// --- package-level shims kept for existing tests ---
|
||||
|
||||
func LeadKeywordsPage(w http.ResponseWriter, r *http.Request) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).LeadKeywordsPage(w, r)
|
||||
}
|
||||
func AddServiceKeyword(w http.ResponseWriter, r *http.Request) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).AddServiceKeyword(w, r)
|
||||
}
|
||||
func DeleteServiceKeywordHandler(w http.ResponseWriter, r *http.Request) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).DeleteServiceKeywordHandler(w, r)
|
||||
}
|
||||
func AddLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).AddLeadStatusHandler(w, r)
|
||||
}
|
||||
func DeleteLeadStatusHandler(w http.ResponseWriter, r *http.Request) {
|
||||
(&App{DB: DB, WAConnector: WAConnector}).DeleteLeadStatusHandler(w, r)
|
||||
}
|
||||
Reference in New Issue
Block a user