Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user