feat: per-location sender ID with admin API
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

Allows each GHL sub-account to use a different Cast sender ID instead of
the global CAST_SENDER_ID default.

- store.TokenRecord gains a sender_id field (MongoDB)
- store.UpdateSenderID method to set it per location
- cast.Client.SendSMS accepts a senderID override param (empty = use
  client-level default)
- webhook.processOutbound reads the location's sender_id from the token
  record and passes it to Cast
- new admin handler: PUT /api/admin/locations/{locationId}/sender-id
  protected by Authorization: Bearer <INBOUND_API_KEY>

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Head of Product & Engineering 2026-04-06 12:47:00 +02:00
parent 3ea663c8dc
commit 5312eb0ca2
8 changed files with 127 additions and 11 deletions

View File

@ -50,6 +50,8 @@ func run() error {
return fmt.Errorf("webhook handler: %w", err) return fmt.Errorf("webhook handler: %w", err)
} }
adminHandler := ghl.NewAdminHandler(cfg.InboundAPIKey, s)
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.RequestID) r.Use(middleware.RequestID)
r.Use(middleware.RealIP) r.Use(middleware.RealIP)
@ -61,6 +63,7 @@ func run() error {
r.Get("/oauth-callback", oauthHandler.HandleCallback) r.Get("/oauth-callback", oauthHandler.HandleCallback)
r.Post("/api/ghl/v1/webhook/messages", webhookHandler.HandleWebhook) r.Post("/api/ghl/v1/webhook/messages", webhookHandler.HandleWebhook)
r.Post("/api/ghl/v1/webhook/uninstall", webhookHandler.HandleUninstall) r.Post("/api/ghl/v1/webhook/uninstall", webhookHandler.HandleUninstall)
r.Put("/api/admin/locations/{locationId}/sender-id", adminHandler.HandleSetSenderID)
srv := &http.Server{ srv := &http.Server{
Addr: ":" + cfg.Port, Addr: ":" + cfg.Port,

View File

@ -29,9 +29,13 @@ func NewClient(baseURL, apiKey, senderID string) *Client {
} }
} }
func (c *Client) SendSMS(ctx context.Context, to, message string) (*SendResponse, error) { // SendSMS sends an SMS via Cast API. senderID overrides the client-level default when non-empty.
func (c *Client) SendSMS(ctx context.Context, to, message, senderID string) (*SendResponse, error) {
req := SendRequest{To: to, Message: message} req := SendRequest{To: to, Message: message}
if c.senderID != "" { switch {
case senderID != "":
req.SenderID = senderID
case c.senderID != "":
req.SenderID = c.senderID req.SenderID = c.senderID
} }

View File

@ -32,7 +32,7 @@ func TestSendSMS_Success(t *testing.T) {
defer srv.Close() defer srv.Close()
client := NewClient(srv.URL, "cast_testkey", "") client := NewClient(srv.URL, "cast_testkey", "")
resp, err := client.SendSMS(context.Background(), "09171234567", "test message") resp, err := client.SendSMS(context.Background(), "09171234567", "test message", "")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -49,7 +49,7 @@ func TestSendSMS_APIError(t *testing.T) {
defer srv.Close() defer srv.Close()
client := NewClient(srv.URL, "cast_testkey", "") client := NewClient(srv.URL, "cast_testkey", "")
_, err := client.SendSMS(context.Background(), "09171234567", "test") _, err := client.SendSMS(context.Background(), "09171234567", "test", "")
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
@ -70,7 +70,7 @@ func TestSendSMS_SuccessFalseInBody(t *testing.T) {
defer srv.Close() defer srv.Close()
client := NewClient(srv.URL, "cast_testkey", "") client := NewClient(srv.URL, "cast_testkey", "")
_, err := client.SendSMS(context.Background(), "09171234567", "test") _, err := client.SendSMS(context.Background(), "09171234567", "test", "")
if err == nil { if err == nil {
t.Fatal("expected error, got nil") t.Fatal("expected error, got nil")
} }
@ -96,7 +96,7 @@ func TestSendSMS_WithSenderID(t *testing.T) {
defer srv.Close() defer srv.Close()
client := NewClient(srv.URL, "cast_testkey", "CAST") client := NewClient(srv.URL, "cast_testkey", "CAST")
_, err := client.SendSMS(context.Background(), "09171234567", "test") _, err := client.SendSMS(context.Background(), "09171234567", "test", "")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -115,7 +115,7 @@ func TestSendSMS_WithoutSenderID(t *testing.T) {
defer srv.Close() defer srv.Close()
client := NewClient(srv.URL, "cast_testkey", "") client := NewClient(srv.URL, "cast_testkey", "")
_, err := client.SendSMS(context.Background(), "09171234567", "test") _, err := client.SendSMS(context.Background(), "09171234567", "test", "")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }
@ -130,7 +130,7 @@ func TestSendSMS_Unauthorized(t *testing.T) {
defer srv.Close() defer srv.Close()
client := NewClient(srv.URL, "cast_badkey", "") client := NewClient(srv.URL, "cast_badkey", "")
_, err := client.SendSMS(context.Background(), "09171234567", "test") _, err := client.SendSMS(context.Background(), "09171234567", "test", "")
if err == nil { if err == nil {
t.Fatal("expected error for 401, got nil") t.Fatal("expected error for 401, got nil")
} }
@ -158,7 +158,7 @@ func TestSendSMS_RetryOn429(t *testing.T) {
defer srv.Close() defer srv.Close()
client := NewClient(srv.URL, "cast_testkey", "") client := NewClient(srv.URL, "cast_testkey", "")
resp, err := client.SendSMS(context.Background(), "09171234567", "test") resp, err := client.SendSMS(context.Background(), "09171234567", "test", "")
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
} }

78
internal/ghl/admin.go Normal file
View File

@ -0,0 +1,78 @@
package ghl
import (
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
)
// LocationConfigStore is the store interface used by AdminHandler.
type LocationConfigStore interface {
UpdateSenderID(ctx context.Context, locationID, senderID string) error
}
// AdminHandler exposes internal management endpoints.
// All routes require a valid Authorization: Bearer <adminKey> header.
type AdminHandler struct {
adminKey string
store LocationConfigStore
}
func NewAdminHandler(adminKey string, store LocationConfigStore) *AdminHandler {
return &AdminHandler{adminKey: adminKey, store: store}
}
// HandleSetSenderID sets or clears the per-location Cast sender ID.
//
// PUT /api/admin/locations/{locationId}/sender-id
// Authorization: Bearer <INBOUND_API_KEY>
// {"sender_id":"CAST"}
func (h *AdminHandler) HandleSetSenderID(w http.ResponseWriter, r *http.Request) {
if h.adminKey == "" || r.Header.Get("Authorization") != "Bearer "+h.adminKey {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
locationID := chi.URLParam(r, "locationId")
if locationID == "" {
http.Error(w, "missing locationId", http.StatusBadRequest)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1024))
if err != nil {
http.Error(w, "failed to read body", http.StatusBadRequest)
return
}
var payload struct {
SenderID string `json:"sender_id"`
}
if err := json.Unmarshal(body, &payload); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := h.store.UpdateSenderID(ctx, locationID, payload.SenderID); err != nil {
slog.Error("admin: failed to update sender_id", "location_id", locationID, "err", err)
if err.Error() == "location not found" {
http.Error(w, "location not found", http.StatusNotFound)
return
}
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
slog.Info("admin: sender_id updated", "location_id", locationID, "sender_id", payload.SenderID)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"ok":true}`))
}

View File

@ -28,6 +28,7 @@ type TokenStore interface {
SaveToken(ctx context.Context, record *store.TokenRecord) error SaveToken(ctx context.Context, record *store.TokenRecord) error
GetToken(ctx context.Context, locationID string) (*store.TokenRecord, error) GetToken(ctx context.Context, locationID string) (*store.TokenRecord, error)
UpdateToken(ctx context.Context, locationID, accessToken, refreshToken string, expiresAt time.Time) error UpdateToken(ctx context.Context, locationID, accessToken, refreshToken string, expiresAt time.Time) error
UpdateSenderID(ctx context.Context, locationID, senderID string) error
DeleteToken(ctx context.Context, locationID string) error DeleteToken(ctx context.Context, locationID string) error
} }

View File

@ -201,6 +201,13 @@ func (m *inMemStore) UpdateToken(_ context.Context, locationID, accessToken, ref
return nil return nil
} }
func (m *inMemStore) UpdateSenderID(_ context.Context, locationID, senderID string) error {
if m.token != nil && m.token.LocationID == locationID {
m.token.SenderID = senderID
}
return nil
}
func (m *inMemStore) DeleteToken(_ context.Context, locationID string) error { func (m *inMemStore) DeleteToken(_ context.Context, locationID string) error {
if m.token != nil && m.token.LocationID == locationID { if m.token != nil && m.token.LocationID == locationID {
m.token = nil m.token = nil

View File

@ -138,8 +138,14 @@ func (h *WebhookHandler) processOutbound(webhook OutboundMessageWebhook) {
return return
} }
slog.Info("webhook: calling cast api", "message_id", webhook.MessageID, "to", localPhone) // Look up per-location sender ID; fall back to Cast client default if unset.
_, err = h.castClient.SendSMS(ctx, localPhone, webhook.Message) var senderID string
if rec, err := h.store.GetToken(ctx, webhook.LocationID); err == nil && rec != nil {
senderID = rec.SenderID
}
slog.Info("webhook: calling cast api", "message_id", webhook.MessageID, "to", localPhone, "sender_id", senderID)
_, err = h.castClient.SendSMS(ctx, localPhone, webhook.Message, senderID)
if err != nil { if err != nil {
slog.Error("webhook: cast send failed", "message_id", webhook.MessageID, "to", localPhone, "err", err) slog.Error("webhook: cast send failed", "message_id", webhook.MessageID, "to", localPhone, "err", err)
h.updateStatus(ctx, webhook, "failed") h.updateStatus(ctx, webhook, "failed")

View File

@ -18,6 +18,7 @@ type TokenRecord struct {
ExpiresAt time.Time `bson:"expires_at"` ExpiresAt time.Time `bson:"expires_at"`
InstalledAt time.Time `bson:"installed_at"` InstalledAt time.Time `bson:"installed_at"`
UpdatedAt time.Time `bson:"updated_at"` UpdatedAt time.Time `bson:"updated_at"`
SenderID string `bson:"sender_id,omitempty"` // per-location Cast sender ID; overrides global default
} }
type Store struct { type Store struct {
@ -83,6 +84,22 @@ func (s *Store) UpdateToken(ctx context.Context, locationID, accessToken, refres
return err return err
} }
func (s *Store) UpdateSenderID(ctx context.Context, locationID, senderID string) error {
filter := bson.D{{Key: "location_id", Value: locationID}}
update := bson.D{{Key: "$set", Value: bson.D{
{Key: "sender_id", Value: senderID},
{Key: "updated_at", Value: time.Now()},
}}}
res, err := s.collection.UpdateOne(ctx, filter, update)
if err != nil {
return err
}
if res.MatchedCount == 0 {
return errors.New("location not found")
}
return nil
}
func (s *Store) DeleteToken(ctx context.Context, locationID string) error { func (s *Store) DeleteToken(ctx context.Context, locationID string) error {
filter := bson.D{{Key: "location_id", Value: locationID}} filter := bson.D{{Key: "location_id", Value: locationID}}
_, err := s.collection.DeleteOne(ctx, filter) _, err := s.collection.DeleteOne(ctx, filter)