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:
@@ -9,8 +9,11 @@ import (
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func SignupPage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
// package-level globals kept for existing tests; App methods use a.DB directly.
|
||||
var DB *sql.DB
|
||||
|
||||
func (a *App) SignupPage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -30,7 +33,7 @@ func SignupPage(w http.ResponseWriter, r *http.Request) {
|
||||
</html>`))
|
||||
}
|
||||
|
||||
func Signup(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) Signup(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
email := r.FormValue("email")
|
||||
name := r.FormValue("name")
|
||||
@@ -47,21 +50,17 @@ func Signup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
account := struct {
|
||||
Email string
|
||||
Name string
|
||||
Password string
|
||||
CreatedAt int64
|
||||
}{
|
||||
Email: email,
|
||||
Name: name,
|
||||
Password: string(hashedPassword),
|
||||
CreatedAt: time.Now().Unix(),
|
||||
// Use a transaction so account + client + session are atomic.
|
||||
tx, err := a.DB.Begin()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to start transaction", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = DB.Exec(
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO accounts (email, name, password, created_at) VALUES (?, ?, ?, ?)",
|
||||
account.Email, account.Name, account.Password, account.CreatedAt,
|
||||
email, name, string(hashedPassword), time.Now().Unix(),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(w, "Email already exists", http.StatusBadRequest)
|
||||
@@ -69,45 +68,40 @@ func Signup(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
var accountID int64
|
||||
err = DB.QueryRow("SELECT account_id FROM accounts WHERE email = ?", email).Scan(&accountID)
|
||||
if err != nil {
|
||||
if err = tx.QueryRow("SELECT account_id FROM accounts WHERE email = ?", email).Scan(&accountID); err != nil {
|
||||
http.Error(w, "Failed to create account", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
client := struct {
|
||||
AccountID int64
|
||||
Name string
|
||||
CreatedAt int64
|
||||
}{
|
||||
AccountID: accountID,
|
||||
Name: name,
|
||||
CreatedAt: time.Now().Unix(),
|
||||
}
|
||||
_, err = DB.Exec(
|
||||
if _, err = tx.Exec(
|
||||
"INSERT INTO clients (account_id, name, created_at) VALUES (?, ?, ?)",
|
||||
client.AccountID, client.Name, client.CreatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
accountID, name, time.Now().Unix(),
|
||||
); err != nil {
|
||||
http.Error(w, "Failed to create client", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
sessionID := generateSessionID()
|
||||
expires := time.Now().Add(24 * time.Hour).Unix()
|
||||
|
||||
_, err = DB.Exec("INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)", sessionID, accountID, expires)
|
||||
if err != nil {
|
||||
if _, err = tx.Exec(
|
||||
"INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)",
|
||||
sessionID, accountID, expires,
|
||||
); err != nil {
|
||||
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
if err = tx.Commit(); err != nil {
|
||||
http.Error(w, "Failed to commit signup", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: sessionID, Path: "/"})
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func LoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
func (a *App) LoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
@@ -126,16 +120,14 @@ func LoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
</html>`))
|
||||
}
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
func Login(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) Login(w http.ResponseWriter, r *http.Request) {
|
||||
r.ParseForm()
|
||||
email := r.FormValue("email")
|
||||
password := r.FormValue("password")
|
||||
|
||||
var accountID int64
|
||||
var hashedPassword string
|
||||
err := DB.QueryRow("SELECT account_id, password FROM accounts WHERE email = ?", email).Scan(&accountID, &hashedPassword)
|
||||
err := a.DB.QueryRow("SELECT account_id, password FROM accounts WHERE email = ?", email).Scan(&accountID, &hashedPassword)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -149,7 +141,7 @@ func Login(w http.ResponseWriter, r *http.Request) {
|
||||
sessionID := generateSessionID()
|
||||
expires := time.Now().Add(24 * time.Hour).Unix()
|
||||
|
||||
_, err = DB.Exec("INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)", sessionID, accountID, expires)
|
||||
_, err = a.DB.Exec("INSERT INTO sessions (session_id, account_id, expires) VALUES (?, ?, ?)", sessionID, accountID, expires)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to create session", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -159,16 +151,152 @@ func Login(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/", http.StatusFound)
|
||||
}
|
||||
|
||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
||||
func (a *App) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err == nil {
|
||||
DB.Exec("DELETE FROM sessions WHERE session_id = ?", cookie.Value)
|
||||
a.DB.Exec("DELETE FROM sessions WHERE session_id = ?", cookie.Value)
|
||||
}
|
||||
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "", Path: "/", MaxAge: -1})
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *App) AccountPage(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var name, email string
|
||||
err := a.DB.QueryRow("SELECT name, email FROM accounts WHERE account_id = ?", accountID).Scan(&name, &email)
|
||||
if err != nil {
|
||||
http.Error(w, "Account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><title>Account</title></head><body><h1>Account Settings</h1><form method="POST" action="/auth/account">
|
||||
<p>Name: <input type="text" name="name" value="` + name + `"></p>
|
||||
<p>Email: <input type="email" value="` + email + `" disabled></p>
|
||||
<p>New Password: <input type="password" name="password" placeholder="Leave blank to keep current"></p>
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
<a href="/">Back to Dashboard</a></body></html>`))
|
||||
}
|
||||
|
||||
func (a *App) UpdateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := a.requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
r.ParseForm()
|
||||
name := r.FormValue("name")
|
||||
password := r.FormValue("password")
|
||||
|
||||
if name != "" {
|
||||
a.DB.Exec("UPDATE accounts SET name = ? WHERE account_id = ?", name, accountID)
|
||||
}
|
||||
|
||||
if password != "" {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err == nil {
|
||||
a.DB.Exec("UPDATE accounts SET password = ? WHERE account_id = ?", string(hashedPassword), accountID)
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/auth/account", http.StatusFound)
|
||||
}
|
||||
|
||||
// --- session helpers on App --------------------------------------------------
|
||||
|
||||
func (a *App) getSession(r *http.Request) (int64, error) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var accountID int64
|
||||
err = a.DB.QueryRow(
|
||||
"SELECT account_id FROM sessions WHERE session_id = ? AND expires > ?",
|
||||
cookie.Value, time.Now().Unix(),
|
||||
).Scan(&accountID)
|
||||
return accountID, err
|
||||
}
|
||||
|
||||
func (a *App) GetAccountID(r *http.Request) (int64, error) {
|
||||
return a.getSession(r)
|
||||
}
|
||||
|
||||
func (a *App) requireAuth(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
accountID, err := a.getSession(r)
|
||||
if err != nil {
|
||||
if isAPIRequest(r) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
} else {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
return accountID, true
|
||||
}
|
||||
|
||||
func (a *App) clientIDForAccount(accountID int64) int64 {
|
||||
var clientID int64
|
||||
a.DB.QueryRow("SELECT client_id FROM clients WHERE account_id = ? LIMIT 1", accountID).Scan(&clientID)
|
||||
return clientID
|
||||
}
|
||||
|
||||
// --- package-level shims kept for existing tests that set the global DB ------
|
||||
|
||||
func GetAccountID(r *http.Request) (int64, error) {
|
||||
return getSession(r)
|
||||
}
|
||||
|
||||
func getSession(r *http.Request) (int64, error) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var accountID int64
|
||||
err = DB.QueryRow(
|
||||
"SELECT account_id FROM sessions WHERE session_id = ? AND expires > ?",
|
||||
cookie.Value, time.Now().Unix(),
|
||||
).Scan(&accountID)
|
||||
return accountID, err
|
||||
}
|
||||
|
||||
func requireAuth(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
accountID, err := getSession(r)
|
||||
if err != nil {
|
||||
if isAPIRequest(r) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
} else {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
return accountID, true
|
||||
}
|
||||
|
||||
func clientIDForAccount(accountID int64) int64 {
|
||||
var clientID int64
|
||||
DB.QueryRow("SELECT client_id FROM clients WHERE account_id = ? LIMIT 1", accountID).Scan(&clientID)
|
||||
return clientID
|
||||
}
|
||||
|
||||
func isAPIRequest(r *http.Request) bool {
|
||||
accept := r.Header.Get("Accept")
|
||||
return accept == "application/json" || r.URL.Path == "/leads/qr"
|
||||
}
|
||||
|
||||
func SetupAuthHandlers(db *sql.DB) {
|
||||
DB = db
|
||||
chi.RegisterMethod("GET")
|
||||
}
|
||||
|
||||
// --- session ID generation ---------------------------------------------------
|
||||
|
||||
func generateSessionID() string {
|
||||
return time.Now().Format("20060102150405") + "-" + randomString(32)
|
||||
}
|
||||
@@ -182,88 +310,41 @@ func randomString(n int) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func getSession(r *http.Request) (int64, error) {
|
||||
cookie, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// SignupPage, Signup, LoginPage, Login, Logout, AccountPage, UpdateAccount
|
||||
// are also kept as package-level functions for backward compat with test
|
||||
// setups that call handlers.Signup directly. They delegate to the globals.
|
||||
|
||||
var accountID int64
|
||||
err = DB.QueryRow("SELECT account_id FROM sessions WHERE session_id = ? AND expires > ?", cookie.Value, time.Now().Unix()).Scan(&accountID)
|
||||
return accountID, err
|
||||
func SignupPage(w http.ResponseWriter, r *http.Request) {
|
||||
a := &App{DB: DB, WAConnector: WAConnector}
|
||||
a.SignupPage(w, r)
|
||||
}
|
||||
|
||||
func GetAccountID(r *http.Request) (int64, error) {
|
||||
return getSession(r)
|
||||
func Signup(w http.ResponseWriter, r *http.Request) {
|
||||
a := &App{DB: DB, WAConnector: WAConnector}
|
||||
a.Signup(w, r)
|
||||
}
|
||||
|
||||
func requireAuth(w http.ResponseWriter, r *http.Request) (int64, bool) {
|
||||
accountID, err := getSession(r)
|
||||
if err != nil {
|
||||
if isAPIRequest(r) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"error":"unauthorized"}`))
|
||||
} else {
|
||||
http.Redirect(w, r, "/auth/login", http.StatusFound)
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
return accountID, true
|
||||
func LoginPage(w http.ResponseWriter, r *http.Request) {
|
||||
a := &App{DB: DB, WAConnector: WAConnector}
|
||||
a.LoginPage(w, r)
|
||||
}
|
||||
|
||||
func isAPIRequest(r *http.Request) bool {
|
||||
accept := r.Header.Get("Accept")
|
||||
return accept == "application/json" || r.URL.Path == "/leads/qr"
|
||||
func Login(w http.ResponseWriter, r *http.Request) {
|
||||
a := &App{DB: DB, WAConnector: WAConnector}
|
||||
a.Login(w, r)
|
||||
}
|
||||
|
||||
func SetupAuthHandlers(db *sql.DB) {
|
||||
DB = db
|
||||
chi.RegisterMethod("GET")
|
||||
func Logout(w http.ResponseWriter, r *http.Request) {
|
||||
a := &App{DB: DB, WAConnector: WAConnector}
|
||||
a.Logout(w, r)
|
||||
}
|
||||
|
||||
func AccountPage(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var name, email string
|
||||
err := DB.QueryRow("SELECT name, email FROM accounts WHERE account_id = ?", accountID).Scan(&name, &email)
|
||||
if err != nil {
|
||||
http.Error(w, "Account not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(`<!DOCTYPE html><html><head><title>Account</title></head><body><h1>Account Settings</h1><form method="POST" action="/auth/account">
|
||||
<p>Name: <input type="text" name="name" value="` + name + `"></p>
|
||||
<p>Email: <input type="email" value="` + email + `" disabled></p>
|
||||
<p>New Password: <input type="password" name="password" placeholder="Leave blank to keep current"></p>
|
||||
<button type="submit">Update</button>
|
||||
</form>
|
||||
<a href="/">Back to Dashboard</a></body></html>`))
|
||||
a := &App{DB: DB, WAConnector: WAConnector}
|
||||
a.AccountPage(w, r)
|
||||
}
|
||||
|
||||
func UpdateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
accountID, ok := requireAuth(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
r.ParseForm()
|
||||
name := r.FormValue("name")
|
||||
password := r.FormValue("password")
|
||||
|
||||
if name != "" {
|
||||
DB.Exec("UPDATE accounts SET name = ? WHERE account_id = ?", name, accountID)
|
||||
}
|
||||
|
||||
if password != "" {
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err == nil {
|
||||
DB.Exec("UPDATE accounts SET password = ? WHERE account_id = ?", string(hashedPassword), accountID)
|
||||
}
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/auth/account", http.StatusFound)
|
||||
}
|
||||
a := &App{DB: DB, WAConnector: WAConnector}
|
||||
a.UpdateAccount(w, r)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user