2026-07-12 13:21:10 +00:00
|
|
|
package handlers
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"database/sql"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
2026-07-14 11:46:06 +00:00
|
|
|
"github.com/lib/pq"
|
2026-07-12 13:21:10 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type CRMHandler struct {
|
|
|
|
|
DB *sql.DB
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func NewCRMHandler(db *sql.DB) *CRMHandler {
|
|
|
|
|
return &CRMHandler{DB: db}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-29 19:03:06 +00:00
|
|
|
// NullString is a sql.NullString that marshals to a plain string in JSON
|
|
|
|
|
type NullString struct {
|
|
|
|
|
sql.NullString
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (ns NullString) MarshalJSON() ([]byte, error) {
|
|
|
|
|
if ns.Valid {
|
|
|
|
|
return json.Marshal(ns.String)
|
|
|
|
|
}
|
|
|
|
|
return json.Marshal("")
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 13:21:10 +00:00
|
|
|
type Customer struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
Name string `json:"name"`
|
|
|
|
|
Email string `json:"email"`
|
2026-07-29 19:03:06 +00:00
|
|
|
Phone NullString `json:"phone"`
|
2026-07-12 13:21:10 +00:00
|
|
|
Company string `json:"company"`
|
2026-07-29 19:03:06 +00:00
|
|
|
OrgNumber NullString `json:"org_number"`
|
2026-07-12 13:21:10 +00:00
|
|
|
Status string `json:"status"`
|
|
|
|
|
Source string `json:"source"`
|
|
|
|
|
Tags []string `json:"tags"`
|
|
|
|
|
AssignedTo *string `json:"assigned_to"`
|
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type CustomerInteraction struct {
|
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
CustomerID string `json:"customer_id"`
|
|
|
|
|
Type string `json:"type"`
|
|
|
|
|
Direction string `json:"direction"`
|
|
|
|
|
Subject string `json:"subject"`
|
|
|
|
|
Content string `json:"content"`
|
|
|
|
|
Metadata map[string]interface{} `json:"metadata"`
|
|
|
|
|
CreatedBy *string `json:"created_by"`
|
|
|
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PipelineStage struct {
|
|
|
|
|
Stage string `json:"stage"`
|
|
|
|
|
Count int `json:"count"`
|
|
|
|
|
Value float64 `json:"value"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *CRMHandler) ListCustomers(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
status := r.URL.Query().Get("status")
|
|
|
|
|
if status == "" {
|
|
|
|
|
status = "active"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rows, err := h.DB.Query(`
|
|
|
|
|
SELECT id, name, email, phone, company, org_number, status, source, tags, assigned_to, created_at, updated_at
|
|
|
|
|
FROM boc_customers
|
|
|
|
|
WHERE status = $1
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
LIMIT 100
|
|
|
|
|
`, status)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
customers := []Customer{}
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var c Customer
|
2026-07-14 11:46:06 +00:00
|
|
|
var tags pq.StringArray
|
2026-07-12 13:21:10 +00:00
|
|
|
if err := rows.Scan(&c.ID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.OrgNumber,
|
2026-07-14 11:46:06 +00:00
|
|
|
&c.Status, &c.Source, &tags, &c.AssignedTo, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
2026-07-12 13:21:10 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-07-14 11:46:06 +00:00
|
|
|
c.Tags = []string(tags)
|
2026-07-12 13:21:10 +00:00
|
|
|
customers = append(customers, c)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
|
|
|
"customers": customers,
|
|
|
|
|
"total": len(customers),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *CRMHandler) CreateCustomer(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req Customer
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "invalid request")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var id string
|
|
|
|
|
err := h.DB.QueryRow(`
|
|
|
|
|
INSERT INTO boc_customers (name, email, phone, company, org_number, status, source, tags)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
|
|
|
RETURNING id
|
2026-07-14 11:46:06 +00:00
|
|
|
`, req.Name, req.Email, req.Phone, req.Company, req.OrgNumber, req.Status, req.Source, pq.Array(req.Tags)).Scan(&id)
|
2026-07-12 13:21:10 +00:00
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "failed to create customer")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
|
|
|
|
"id": id,
|
|
|
|
|
"message": "Customer created",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *CRMHandler) GetCustomer(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
id := chi.URLParam(r, "id")
|
|
|
|
|
|
|
|
|
|
var c Customer
|
2026-07-14 11:46:06 +00:00
|
|
|
var tags pq.StringArray
|
2026-07-12 13:21:10 +00:00
|
|
|
err := h.DB.QueryRow(`
|
|
|
|
|
SELECT id, name, email, phone, company, org_number, status, source, tags, assigned_to, created_at, updated_at
|
|
|
|
|
FROM boc_customers WHERE id = $1
|
|
|
|
|
`, id).Scan(&c.ID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.OrgNumber,
|
2026-07-14 11:46:06 +00:00
|
|
|
&c.Status, &c.Source, &tags, &c.AssignedTo, &c.CreatedAt, &c.UpdatedAt)
|
|
|
|
|
c.Tags = []string(tags)
|
2026-07-12 13:21:10 +00:00
|
|
|
|
|
|
|
|
if err == sql.ErrNoRows {
|
|
|
|
|
writeError(w, http.StatusNotFound, "customer not found")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusOK, c)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *CRMHandler) UpdateCustomer(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
id := chi.URLParam(r, "id")
|
|
|
|
|
|
|
|
|
|
var req Customer
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "invalid request")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
_, err := h.DB.Exec(`
|
|
|
|
|
UPDATE boc_customers
|
|
|
|
|
SET name = $1, email = $2, phone = $3, company = $4, org_number = $5,
|
|
|
|
|
status = $6, source = $7, tags = $8, assigned_to = $9
|
|
|
|
|
WHERE id = $10
|
|
|
|
|
`, req.Name, req.Email, req.Phone, req.Company, req.OrgNumber,
|
2026-07-14 11:46:06 +00:00
|
|
|
req.Status, req.Source, pq.Array(req.Tags), req.AssignedTo, id)
|
2026-07-12 13:21:10 +00:00
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "failed to update customer")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
|
|
|
"message": "Customer updated",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *CRMHandler) DeleteCustomer(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
id := chi.URLParam(r, "id")
|
|
|
|
|
|
|
|
|
|
_, err := h.DB.Exec(`DELETE FROM boc_customers WHERE id = $1`, id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "failed to delete customer")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
|
|
|
"message": "Customer deleted",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *CRMHandler) ListLeads(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
rows, err := h.DB.Query(`
|
|
|
|
|
SELECT id, name, email, phone, company, org_number, status, source, tags, assigned_to, created_at, updated_at
|
|
|
|
|
FROM boc_customers
|
|
|
|
|
WHERE status = 'lead'
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
leads := []Customer{}
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var c Customer
|
2026-07-14 11:46:06 +00:00
|
|
|
var tags pq.StringArray
|
2026-07-12 13:21:10 +00:00
|
|
|
if err := rows.Scan(&c.ID, &c.Name, &c.Email, &c.Phone, &c.Company, &c.OrgNumber,
|
2026-07-14 11:46:06 +00:00
|
|
|
&c.Status, &c.Source, &tags, &c.AssignedTo, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
2026-07-12 13:21:10 +00:00
|
|
|
continue
|
|
|
|
|
}
|
2026-07-14 11:46:06 +00:00
|
|
|
c.Tags = []string(tags)
|
2026-07-12 13:21:10 +00:00
|
|
|
leads = append(leads, c)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
|
|
|
"leads": leads,
|
|
|
|
|
"total": len(leads),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *CRMHandler) GetPipeline(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
rows, err := h.DB.Query(`
|
|
|
|
|
SELECT stage, COUNT(*), COALESCE(SUM(value), 0)
|
|
|
|
|
FROM boc_deals
|
|
|
|
|
WHERE status = 'open'
|
|
|
|
|
GROUP BY stage
|
|
|
|
|
ORDER BY
|
|
|
|
|
CASE stage
|
|
|
|
|
WHEN 'prospect' THEN 1
|
|
|
|
|
WHEN 'qualified' THEN 2
|
|
|
|
|
WHEN 'proposal' THEN 3
|
|
|
|
|
WHEN 'negotiation' THEN 4
|
|
|
|
|
WHEN 'closed_won' THEN 5
|
|
|
|
|
ELSE 6
|
|
|
|
|
END
|
|
|
|
|
`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
stages := []PipelineStage{}
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var s PipelineStage
|
|
|
|
|
if err := rows.Scan(&s.Stage, &s.Count, &s.Value); err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
stages = append(stages, s)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
|
|
|
"pipeline": stages,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *CRMHandler) CreateInteraction(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
var req CustomerInteraction
|
|
|
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "invalid request")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
metadata, _ := json.Marshal(req.Metadata)
|
|
|
|
|
|
|
|
|
|
var id string
|
|
|
|
|
err := h.DB.QueryRow(`
|
|
|
|
|
INSERT INTO boc_customer_interactions (customer_id, type, direction, subject, content, metadata)
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, $6)
|
|
|
|
|
RETURNING id
|
|
|
|
|
`, req.CustomerID, req.Type, req.Direction, req.Subject, req.Content, metadata).Scan(&id)
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "failed to create interaction")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]interface{}{
|
|
|
|
|
"id": id,
|
|
|
|
|
"message": "Interaction created",
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *CRMHandler) GetCustomerInteractions(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
customerID := chi.URLParam(r, "id")
|
|
|
|
|
|
|
|
|
|
rows, err := h.DB.Query(`
|
|
|
|
|
SELECT id, customer_id, type, direction, subject, content, metadata, created_by, created_at
|
|
|
|
|
FROM boc_customer_interactions
|
|
|
|
|
WHERE customer_id = $1
|
|
|
|
|
ORDER BY created_at DESC
|
|
|
|
|
`, customerID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "database error")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
interactions := []CustomerInteraction{}
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var i CustomerInteraction
|
|
|
|
|
var metadata []byte
|
|
|
|
|
if err := rows.Scan(&i.ID, &i.CustomerID, &i.Type, &i.Direction, &i.Subject,
|
|
|
|
|
&i.Content, &metadata, &i.CreatedBy, &i.CreatedAt); err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
json.Unmarshal(metadata, &i.Metadata)
|
|
|
|
|
interactions = append(interactions, i)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
|
|
|
|
"interactions": interactions,
|
|
|
|
|
"total": len(interactions),
|
|
|
|
|
})
|
|
|
|
|
}
|