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:
2026-08-09 22:47:43 +02:00
commit 8580c996c3
1285 changed files with 325780 additions and 0 deletions
@@ -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
}