mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 16:01:16 +00:00
15ef9d4994
Add warmup content generation and admin review surfaces, plus mailbox warmup appeal/status APIs. Track warmup engagement and tampering signals so unsafe mailboxes can be handled by the warmup flow.
344 lines
10 KiB
Go
344 lines
10 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"net/http"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/app/warmupcontent"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
"github.com/warmbly/warmbly/internal/repository"
|
|
)
|
|
|
|
const warmupContentEntity models.AuditEntityType = "warmup_content"
|
|
|
|
// --- cursor helpers (opaque base64 offset) ---
|
|
|
|
func decodeOffsetCursor(s string) (int, bool) {
|
|
if s == "" {
|
|
return 0, true
|
|
}
|
|
b, err := base64.StdEncoding.DecodeString(s)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
n, err := strconv.Atoi(string(b))
|
|
if err != nil || n < 0 {
|
|
return 0, false
|
|
}
|
|
return n, true
|
|
}
|
|
|
|
func encodeOffsetCursor(n int) string {
|
|
return base64.StdEncoding.EncodeToString([]byte(strconv.Itoa(n)))
|
|
}
|
|
|
|
type pageMeta struct {
|
|
Total int `json:"total"`
|
|
HasMore bool `json:"has_more"`
|
|
NextCursor *string `json:"next_cursor"`
|
|
}
|
|
|
|
func pageMetaFor(offset, limit, returned, total int) pageMeta {
|
|
hasMore := offset+returned < total
|
|
var next *string
|
|
if hasMore {
|
|
c := encodeOffsetCursor(offset + limit)
|
|
next = &c
|
|
}
|
|
return pageMeta{Total: total, HasMore: hasMore, NextCursor: next}
|
|
}
|
|
|
|
// AdminWarmupContentOverview returns content-bank counts + generator status.
|
|
func (h *Handler) AdminWarmupContentOverview(c *gin.Context) {
|
|
ctx := c.Request.Context()
|
|
stats, err := h.WarmupContentRepo.ConversationStats(ctx)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
totalActive, totalArchived := 0, 0
|
|
for _, s := range stats {
|
|
totalActive += s.Active
|
|
totalArchived += s.Archived
|
|
}
|
|
lastGen, _ := h.WarmupContentRepo.LastGeneratedAt(ctx)
|
|
settings, _ := h.WarmupContentRepo.GetGenerationSettings(ctx)
|
|
if settings == nil {
|
|
def := models.DefaultWarmupGenerationSettings()
|
|
settings = &def
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"total_active": totalActive,
|
|
"total_archived": totalArchived,
|
|
"by_pool": stats,
|
|
"last_generated_at": lastGen,
|
|
"ai_enabled": settings.Enabled,
|
|
"schedule_enabled": settings.ScheduleEnabled,
|
|
})
|
|
}
|
|
|
|
type conversationListItem struct {
|
|
ID uuid.UUID `json:"id"`
|
|
PoolType string `json:"pool_type"`
|
|
Segment string `json:"segment"`
|
|
Source string `json:"source"`
|
|
Theme string `json:"theme"`
|
|
Subject string `json:"subject"`
|
|
Description string `json:"description"`
|
|
MessageCount int `json:"message_count"`
|
|
Status string `json:"status"`
|
|
LintPassed bool `json:"lint_passed"`
|
|
UsageCount int64 `json:"usage_count"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
// AdminListWarmupConversations lists cached conversations with filters.
|
|
func (h *Handler) AdminListWarmupConversations(c *gin.Context) {
|
|
offset, ok := decodeOffsetCursor(c.Query("cursor"))
|
|
if !ok {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid cursor"))
|
|
return
|
|
}
|
|
limit := parseLimit(c.Query("limit"), 50)
|
|
|
|
f := repository.ConversationFilter{
|
|
PoolType: c.Query("pool"),
|
|
Segment: c.Query("segment"),
|
|
Source: c.Query("source"),
|
|
Status: c.Query("status"),
|
|
Limit: limit,
|
|
Offset: offset,
|
|
}
|
|
rows, total, err := h.WarmupContentRepo.ListConversations(c.Request.Context(), f)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
|
|
items := make([]conversationListItem, 0, len(rows))
|
|
for _, r := range rows {
|
|
items = append(items, conversationListItem{
|
|
ID: r.ID, PoolType: r.PoolType, Segment: r.Segment, Source: r.Source,
|
|
Theme: r.Theme, Subject: r.Subject, Description: r.Description,
|
|
MessageCount: len(r.Messages), Status: r.Status, LintPassed: r.LintPassed,
|
|
UsageCount: r.UsageCount, CreatedAt: r.CreatedAt,
|
|
})
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": items, "pagination": pageMetaFor(offset, limit, len(rows), total)})
|
|
}
|
|
|
|
// AdminGetWarmupConversation returns a single conversation in full.
|
|
func (h *Handler) AdminGetWarmupConversation(c *gin.Context) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
|
return
|
|
}
|
|
conv, err := h.WarmupContentRepo.GetConversation(c.Request.Context(), id)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
if conv == nil {
|
|
errx.JSON(c, errx.New(errx.NotFound, "conversation not found"))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": conv})
|
|
}
|
|
|
|
func (h *Handler) setConversationStatus(c *gin.Context, status string) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
|
return
|
|
}
|
|
if err := h.WarmupContentRepo.SetConversationStatus(c.Request.Context(), id, status); err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
h.audit(c, models.AuditActionUpdate, warmupContentEntity, &id, map[string]string{"status": status})
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
// AdminArchiveWarmupConversation archives a conversation (excludes it from selection).
|
|
func (h *Handler) AdminArchiveWarmupConversation(c *gin.Context) {
|
|
h.setConversationStatus(c, "archived")
|
|
}
|
|
|
|
// AdminUnarchiveWarmupConversation re-activates a conversation.
|
|
func (h *Handler) AdminUnarchiveWarmupConversation(c *gin.Context) {
|
|
h.setConversationStatus(c, "active")
|
|
}
|
|
|
|
// AdminDeleteWarmupConversation permanently removes a conversation.
|
|
func (h *Handler) AdminDeleteWarmupConversation(c *gin.Context) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
|
return
|
|
}
|
|
if err := h.WarmupContentRepo.DeleteConversation(c.Request.Context(), id); err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
h.audit(c, models.AuditActionDelete, warmupContentEntity, &id, nil)
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
type generateWarmupRequest struct {
|
|
Count int `json:"count"`
|
|
PoolType string `json:"pool_type"`
|
|
Segment string `json:"segment"`
|
|
Theme string `json:"theme"`
|
|
Model string `json:"model"`
|
|
}
|
|
|
|
// AdminGenerateWarmupContent kicks off an offline generation run.
|
|
func (h *Handler) AdminGenerateWarmupContent(c *gin.Context) {
|
|
if h.WarmupContentService == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "warmup generation is not configured"))
|
|
return
|
|
}
|
|
var req generateWarmupRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
|
|
adminID := middleware.GetAdminUserID(c)
|
|
jobID, err := h.WarmupContentService.Generate(c.Request.Context(), warmupcontent.GenerateRequest{
|
|
RequestedBy: adminID,
|
|
Trigger: "manual",
|
|
PoolType: req.PoolType,
|
|
Segment: req.Segment,
|
|
Theme: req.Theme,
|
|
Model: req.Model,
|
|
Count: req.Count,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, warmupcontent.ErrNotConfigured) {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "warmup AI generation is not configured (set OPENAI_API_KEY)"))
|
|
return
|
|
}
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
|
|
h.audit(c, models.AuditActionCreate, warmupContentEntity, &jobID, map[string]string{
|
|
"pool_type": req.PoolType,
|
|
"segment": req.Segment,
|
|
"count": strconv.Itoa(req.Count),
|
|
})
|
|
c.JSON(http.StatusOK, gin.H{"job_id": jobID})
|
|
}
|
|
|
|
// AdminListWarmupGenerationJobs lists generation runs (visibility).
|
|
func (h *Handler) AdminListWarmupGenerationJobs(c *gin.Context) {
|
|
offset, ok := decodeOffsetCursor(c.Query("cursor"))
|
|
if !ok {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid cursor"))
|
|
return
|
|
}
|
|
limit := parseLimit(c.Query("limit"), 50)
|
|
|
|
jobs, total, err := h.WarmupContentRepo.ListGenerationJobs(c.Request.Context(), limit, offset)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": jobs, "pagination": pageMetaFor(offset, limit, len(jobs), total)})
|
|
}
|
|
|
|
// AdminGetWarmupGenerationJob returns one generation run.
|
|
func (h *Handler) AdminGetWarmupGenerationJob(c *gin.Context) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid id"))
|
|
return
|
|
}
|
|
job, err := h.WarmupContentRepo.GetGenerationJob(c.Request.Context(), id)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
if job == nil {
|
|
errx.JSON(c, errx.New(errx.NotFound, "job not found"))
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": job})
|
|
}
|
|
|
|
// AdminGetWarmupGenerationSettings returns the current generation settings.
|
|
func (h *Handler) AdminGetWarmupGenerationSettings(c *gin.Context) {
|
|
settings, err := h.WarmupContentRepo.GetGenerationSettings(c.Request.Context())
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": settings})
|
|
}
|
|
|
|
// AdminUpdateWarmupGenerationSettings replaces the generation settings.
|
|
func (h *Handler) AdminUpdateWarmupGenerationSettings(c *gin.Context) {
|
|
var settings models.WarmupGenerationSettings
|
|
if err := c.ShouldBindJSON(&settings); err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid request body"))
|
|
return
|
|
}
|
|
adminID := middleware.GetAdminUserID(c)
|
|
if err := h.WarmupContentRepo.SetGenerationSettings(c.Request.Context(), &settings, adminID); err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
h.audit(c, models.AuditActionUpdate, warmupContentEntity, nil, map[string]string{
|
|
"enabled": strconv.FormatBool(settings.Enabled),
|
|
"schedule_enabled": strconv.FormatBool(settings.ScheduleEnabled),
|
|
})
|
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
|
}
|
|
|
|
type abRow struct {
|
|
ContentSource string `json:"content_source"`
|
|
Sent int `json:"sent"`
|
|
SpamPlacements int `json:"spam_placements"`
|
|
SpamPlacementRate float64 `json:"spam_placement_rate"`
|
|
}
|
|
|
|
// AdminWarmupContentAB returns spam-placement rate by content cohort.
|
|
func (h *Handler) AdminWarmupContentAB(c *gin.Context) {
|
|
days := 30
|
|
if v := c.Query("days"); v != "" {
|
|
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 365 {
|
|
days = n
|
|
}
|
|
}
|
|
since := time.Now().AddDate(0, 0, -days)
|
|
stats, err := h.WarmupContentRepo.SpamPlacementByCohort(c.Request.Context(), since)
|
|
if err != nil {
|
|
errx.JSON(c, errx.InternalError())
|
|
return
|
|
}
|
|
rows := make([]abRow, 0, len(stats))
|
|
for _, s := range stats {
|
|
rate := 0.0
|
|
if s.Sent > 0 {
|
|
rate = float64(s.SpamPlacements) / float64(s.Sent) * 100
|
|
}
|
|
rows = append(rows, abRow{
|
|
ContentSource: s.ContentSource, Sent: s.Sent,
|
|
SpamPlacements: s.SpamPlacements, SpamPlacementRate: rate,
|
|
})
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"data": rows, "window_days": days})
|
|
}
|