package httpapi import ( "errors" "io" "net/http" "github.com/descrybe/descrybe-v2/apps/api/internal/billing" "github.com/descrybe/descrybe-v2/apps/api/internal/campaigns" "github.com/go-chi/chi/v5" "github.com/google/uuid" ) func (s *Server) handleListCampaignTemplates(w http.ResponseWriter, r *http.Request) { if !s.requireFeatures(w, r, "marketing.campaigns") { return } JSON(w, http.StatusOK, map[string]any{"templates": campaigns.ListTemplates()}) } func (s *Server) handleListCampaigns(w http.ResponseWriter, r *http.Request) { limit, offset := ParseLimitOffset(r) if !s.requireFeatures(w, r, "marketing.campaigns") { return } if s.Campaigns == nil { JSON(w, http.StatusOK, map[string]any{"campaigns": []any{}, "total": 0, "limit": limit, "offset": offset}) return } cid, _ := CompanyIDFromContext(r.Context()) items, total, err := s.Campaigns.List(r.Context(), cid, limit, offset) if err != nil { // First-run / missing migration: empty list so the UI empty-state works. JSON(w, http.StatusOK, map[string]any{"campaigns": []any{}, "total": 0, "limit": limit, "offset": offset}) return } if items == nil { items = []campaigns.Campaign{} } JSON(w, http.StatusOK, map[string]any{"campaigns": items, "total": total, "limit": limit, "offset": offset}) } func (s *Server) handleCreateCampaign(w http.ResponseWriter, r *http.Request) { if s.Campaigns == nil { Error(w, http.StatusServiceUnavailable, "campaigns unavailable") return } if !s.requireFeatures(w, r, "marketing.campaigns", "marketing.campaigns.create") { return } cid, _ := CompanyIDFromContext(r.Context()) uid, _ := UserIDFromContext(r.Context()) var body campaigns.CreateInput if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Campaigns.Create(r.Context(), cid, &uid, body) if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not create campaign", err, campaigns.ClientError) return } JSON(w, http.StatusCreated, item) } func (s *Server) handleGetCampaign(w http.ResponseWriter, r *http.Request) { if s.Campaigns == nil { Error(w, http.StatusServiceUnavailable, "campaigns unavailable") return } if !s.requireFeatures(w, r, "marketing.campaigns") { return } cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } item, err := s.Campaigns.Get(r.Context(), cid, id) if errors.Is(err, campaigns.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if err != nil { Error(w, http.StatusInternalServerError, "get failed") return } JSON(w, http.StatusOK, item) } func (s *Server) handleUpdateCampaign(w http.ResponseWriter, r *http.Request) { if !s.requireFeatures(w, r, "marketing.campaigns", "marketing.campaigns.create") { return } cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } var body campaigns.UpdateInput if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Campaigns.Update(r.Context(), cid, id, body) if errors.Is(err, campaigns.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if err != nil { ClientOrLog(w, http.StatusBadRequest, "could not update campaign", err, campaigns.ClientError) return } JSON(w, http.StatusOK, item) } func (s *Server) handleDeleteCampaign(w http.ResponseWriter, r *http.Request) { if !s.requireFeatures(w, r, "marketing.campaigns", "marketing.campaigns.create") { return } cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } if err := s.Campaigns.Delete(r.Context(), cid, id); errors.Is(err, campaigns.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } else if err != nil { Error(w, http.StatusInternalServerError, "delete failed") return } JSON(w, http.StatusOK, map[string]string{"status": "ok"}) } func (s *Server) handleGenerateCampaign(w http.ResponseWriter, r *http.Request) { if !s.requireFeatures(w, r, "marketing.campaigns") { return } cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } var body campaigns.GenerateInput err = DecodeJSON(r, &body) if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, errJSONBodyTooLarge) { // Allow empty body (defaults to template mode). if r.ContentLength > 0 { Error(w, http.StatusBadRequest, "invalid json") return } } item, err := s.Campaigns.Generate(r.Context(), cid, id, body) if errors.Is(err, campaigns.ErrNotFound) { Error(w, http.StatusNotFound, "not found") return } if writePlanGate(w, err) { return } if errors.Is(err, campaigns.ErrAIRequiresUpgrade) || errors.Is(err, billing.ErrAIRequiresUpgrade) { JSON(w, http.StatusPaymentRequired, map[string]any{ "error": err.Error(), "code": "ai_requires_upgrade", "upgrade_url": "/pricing", }) return } if errors.Is(err, campaigns.ErrInsufficientCredits) || errors.Is(err, billing.ErrInsufficientCredits) { JSON(w, http.StatusPaymentRequired, map[string]any{ "error": err.Error(), "code": "insufficient_credits", "upgrade_url": "/pricing", }) return } if errors.Is(err, campaigns.ErrRateLimited) { w.Header().Set("Retry-After", "60") Error(w, http.StatusTooManyRequests, err.Error()) return } if err != nil { if writeCampaignClientErr(w, err) { return } LogAndError(w, http.StatusBadRequest, "campaign generate failed", err) return } JSON(w, http.StatusOK, item) } func (s *Server) handleSendTestCampaign(w http.ResponseWriter, r *http.Request) { if !s.requireFeatures(w, r, "marketing.campaigns") { return } cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } var body campaigns.SendTestInput if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Campaigns.SendTest(r.Context(), cid, id, body) if writeCampaignSendErr(w, err) { return } JSON(w, http.StatusOK, item) } func (s *Server) handleScheduleCampaign(w http.ResponseWriter, r *http.Request) { if !s.requireFeatures(w, r, "marketing.campaigns") { return } cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } var body campaigns.ScheduleInput if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Campaigns.Schedule(r.Context(), cid, id, body) if writeCampaignSendErr(w, err) { return } JSON(w, http.StatusOK, item) } func (s *Server) handleSendCampaign(w http.ResponseWriter, r *http.Request) { if !s.requireFeatures(w, r, "marketing.campaigns") { return } cid, _ := CompanyIDFromContext(r.Context()) id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { Error(w, http.StatusBadRequest, "invalid id") return } var body campaigns.SendInput if err := DecodeJSON(r, &body); err != nil { Error(w, http.StatusBadRequest, "invalid json") return } item, err := s.Campaigns.Send(r.Context(), cid, id, body) if writeCampaignSendErr(w, err) { return } JSON(w, http.StatusOK, item) } // writeCampaignSendErr writes an error response and returns true when err != nil. func writeCampaignSendErr(w http.ResponseWriter, err error) bool { if err == nil { return false } if writePlanGate(w, err) { return true } switch { case errors.Is(err, campaigns.ErrNotFound): Error(w, http.StatusNotFound, "not found") case errors.Is(err, campaigns.ErrProviderNotFound), errors.Is(err, campaigns.ErrProviderUnverified): JSON(w, http.StatusPreconditionFailed, map[string]any{ "error": err.Error(), "code": "email_not_verified", }) case errors.Is(err, campaigns.ErrRateLimited): w.Header().Set("Retry-After", "60") Error(w, http.StatusTooManyRequests, err.Error()) default: if writeCampaignClientErr(w, err) { return true } LogAndError(w, http.StatusBadRequest, "campaign send failed", err) } return true } // writeCampaignClientErr maps known campaign validation sentinels to 400. func writeCampaignClientErr(w http.ResponseWriter, err error) bool { if msg, ok := campaigns.ClientError(err); ok { Error(w, http.StatusBadRequest, msg) return true } return false }