diff --git a/cmd/server/main.go b/cmd/server/main.go index c2fd709..fc87266 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -50,6 +50,8 @@ func run() error { return fmt.Errorf("webhook handler: %w", err) } + adminHandler := ghl.NewAdminHandler(cfg.InboundAPIKey, s) + r := chi.NewRouter() r.Use(middleware.RequestID) r.Use(middleware.RealIP) @@ -61,6 +63,7 @@ func run() error { r.Get("/oauth-callback", oauthHandler.HandleCallback) r.Post("/api/ghl/v1/webhook/messages", webhookHandler.HandleWebhook) r.Post("/api/ghl/v1/webhook/uninstall", webhookHandler.HandleUninstall) + r.Put("/api/admin/locations/{locationId}/sender-id", adminHandler.HandleSetSenderID) srv := &http.Server{ Addr: ":" + cfg.Port, diff --git a/internal/cast/client.go b/internal/cast/client.go index 786a86d..b7ddbec 100644 --- a/internal/cast/client.go +++ b/internal/cast/client.go @@ -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} - if c.senderID != "" { + switch { + case senderID != "": + req.SenderID = senderID + case c.senderID != "": req.SenderID = c.senderID } diff --git a/internal/cast/client_test.go b/internal/cast/client_test.go index d4d605c..eddacc2 100644 --- a/internal/cast/client_test.go +++ b/internal/cast/client_test.go @@ -32,7 +32,7 @@ func TestSendSMS_Success(t *testing.T) { defer srv.Close() 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 { t.Fatalf("unexpected error: %v", err) } @@ -49,7 +49,7 @@ func TestSendSMS_APIError(t *testing.T) { defer srv.Close() client := NewClient(srv.URL, "cast_testkey", "") - _, err := client.SendSMS(context.Background(), "09171234567", "test") + _, err := client.SendSMS(context.Background(), "09171234567", "test", "") if err == nil { t.Fatal("expected error, got nil") } @@ -70,7 +70,7 @@ func TestSendSMS_SuccessFalseInBody(t *testing.T) { defer srv.Close() client := NewClient(srv.URL, "cast_testkey", "") - _, err := client.SendSMS(context.Background(), "09171234567", "test") + _, err := client.SendSMS(context.Background(), "09171234567", "test", "") if err == nil { t.Fatal("expected error, got nil") } @@ -96,7 +96,7 @@ func TestSendSMS_WithSenderID(t *testing.T) { defer srv.Close() client := NewClient(srv.URL, "cast_testkey", "CAST") - _, err := client.SendSMS(context.Background(), "09171234567", "test") + _, err := client.SendSMS(context.Background(), "09171234567", "test", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -115,7 +115,7 @@ func TestSendSMS_WithoutSenderID(t *testing.T) { defer srv.Close() client := NewClient(srv.URL, "cast_testkey", "") - _, err := client.SendSMS(context.Background(), "09171234567", "test") + _, err := client.SendSMS(context.Background(), "09171234567", "test", "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -130,7 +130,7 @@ func TestSendSMS_Unauthorized(t *testing.T) { defer srv.Close() client := NewClient(srv.URL, "cast_badkey", "") - _, err := client.SendSMS(context.Background(), "09171234567", "test") + _, err := client.SendSMS(context.Background(), "09171234567", "test", "") if err == nil { t.Fatal("expected error for 401, got nil") } @@ -158,7 +158,7 @@ func TestSendSMS_RetryOn429(t *testing.T) { defer srv.Close() 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 { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/ghl/admin.go b/internal/ghl/admin.go new file mode 100644 index 0000000..4e79279 --- /dev/null +++ b/internal/ghl/admin.go @@ -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 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 +// {"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}`)) +} diff --git a/internal/ghl/oauth.go b/internal/ghl/oauth.go index 6f5fd0b..555fe54 100644 --- a/internal/ghl/oauth.go +++ b/internal/ghl/oauth.go @@ -28,6 +28,7 @@ type TokenStore interface { SaveToken(ctx context.Context, record *store.TokenRecord) error GetToken(ctx context.Context, locationID string) (*store.TokenRecord, 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 } diff --git a/internal/ghl/oauth_test.go b/internal/ghl/oauth_test.go index 5db217c..c96586f 100644 --- a/internal/ghl/oauth_test.go +++ b/internal/ghl/oauth_test.go @@ -201,6 +201,13 @@ func (m *inMemStore) UpdateToken(_ context.Context, locationID, accessToken, ref 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 { if m.token != nil && m.token.LocationID == locationID { m.token = nil diff --git a/internal/ghl/webhook.go b/internal/ghl/webhook.go index 4d7a82d..cf92f8f 100644 --- a/internal/ghl/webhook.go +++ b/internal/ghl/webhook.go @@ -138,8 +138,14 @@ func (h *WebhookHandler) processOutbound(webhook OutboundMessageWebhook) { return } - slog.Info("webhook: calling cast api", "message_id", webhook.MessageID, "to", localPhone) - _, err = h.castClient.SendSMS(ctx, localPhone, webhook.Message) + // Look up per-location sender ID; fall back to Cast client default if unset. + 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 { slog.Error("webhook: cast send failed", "message_id", webhook.MessageID, "to", localPhone, "err", err) h.updateStatus(ctx, webhook, "failed") diff --git a/internal/store/mongo.go b/internal/store/mongo.go index 5966b23..d363a88 100644 --- a/internal/store/mongo.go +++ b/internal/store/mongo.go @@ -18,6 +18,7 @@ type TokenRecord struct { ExpiresAt time.Time `bson:"expires_at"` InstalledAt time.Time `bson:"installed_at"` UpdatedAt time.Time `bson:"updated_at"` + SenderID string `bson:"sender_id,omitempty"` // per-location Cast sender ID; overrides global default } type Store struct { @@ -83,6 +84,22 @@ func (s *Store) UpdateToken(ctx context.Context, locationID, accessToken, refres 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 { filter := bson.D{{Key: "location_id", Value: locationID}} _, err := s.collection.DeleteOne(ctx, filter)