mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-19 00:01:14 +00:00
e0ced97ba1
Move audit logs into org-scoped Postgres storage, wire audit events across backend handlers, and surface actor details in the dashboard activity log. Add realtime audit invalidation and retention pruning so the trail stays current and bounded.
184 lines
5.8 KiB
Go
184 lines
5.8 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// warmupRoutingRulePayload is the wire shape for create/update requests.
|
|
// Kept separate from models.WarmupRoutingRule so server-managed fields
|
|
// (id, organization_id, created_at, updated_at) cannot be set by clients.
|
|
type warmupRoutingRulePayload struct {
|
|
Name string `json:"name"`
|
|
Priority int `json:"priority"`
|
|
SenderMatchType string `json:"sender_match_type"`
|
|
SenderMatchValue string `json:"sender_match_value"`
|
|
RecipientMatchType string `json:"recipient_match_type"`
|
|
RecipientMatchValue string `json:"recipient_match_value"`
|
|
Weight float64 `json:"weight"`
|
|
Enabled bool `json:"enabled"`
|
|
}
|
|
|
|
func (p *warmupRoutingRulePayload) toModel(orgID, ruleID uuid.UUID) *models.WarmupRoutingRule {
|
|
return &models.WarmupRoutingRule{
|
|
ID: ruleID,
|
|
OrganizationID: orgID,
|
|
Name: strings.TrimSpace(p.Name),
|
|
Priority: p.Priority,
|
|
SenderMatchType: models.WarmupRoutingMatchType(p.SenderMatchType),
|
|
SenderMatchValue: strings.ToLower(strings.TrimSpace(p.SenderMatchValue)),
|
|
RecipientMatchType: models.WarmupRoutingMatchType(p.RecipientMatchType),
|
|
RecipientMatchValue: strings.ToLower(strings.TrimSpace(p.RecipientMatchValue)),
|
|
Weight: p.Weight,
|
|
Enabled: p.Enabled,
|
|
}
|
|
}
|
|
|
|
func validateRoutingPayload(p *warmupRoutingRulePayload) *errx.Error {
|
|
if strings.TrimSpace(p.Name) == "" {
|
|
return errx.New(errx.BadRequest, "name is required")
|
|
}
|
|
if !isValidMatchType(p.SenderMatchType) {
|
|
return errx.New(errx.BadRequest, "invalid sender_match_type")
|
|
}
|
|
if !isValidMatchType(p.RecipientMatchType) {
|
|
return errx.New(errx.BadRequest, "invalid recipient_match_type")
|
|
}
|
|
if p.SenderMatchType != string(models.WarmupMatchAny) && p.SenderMatchValue == "" {
|
|
return errx.New(errx.BadRequest, "sender_match_value is required for non-any match types")
|
|
}
|
|
if p.RecipientMatchType != string(models.WarmupMatchAny) && p.RecipientMatchValue == "" {
|
|
return errx.New(errx.BadRequest, "recipient_match_value is required for non-any match types")
|
|
}
|
|
if p.Weight < 0 {
|
|
return errx.New(errx.BadRequest, "weight must be >= 0")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isValidMatchType(t string) bool {
|
|
switch models.WarmupRoutingMatchType(t) {
|
|
case models.WarmupMatchAny, models.WarmupMatchDomain, models.WarmupMatchTLD, models.WarmupMatchProvider:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ListWarmupRoutingRules returns every rule for the caller's organization,
|
|
// ordered by priority (ascending — first to evaluate).
|
|
func (h *Handler) ListWarmupRoutingRules(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
rules, err := h.WarmupRoutingRepo.ListForOrganization(c.Request.Context(), orgID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list rules"})
|
|
return
|
|
}
|
|
if rules == nil {
|
|
rules = []models.WarmupRoutingRule{}
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"rules": rules})
|
|
}
|
|
|
|
// CreateWarmupRoutingRule creates a new rule for the caller's organization.
|
|
func (h *Handler) CreateWarmupRoutingRule(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var payload warmupRoutingRulePayload
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
if xerr := validateRoutingPayload(&payload); xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
rule := payload.toModel(orgID, uuid.New())
|
|
if err := h.WarmupRoutingRepo.Create(c.Request.Context(), rule); err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create rule"})
|
|
return
|
|
}
|
|
|
|
ruleID := rule.ID
|
|
h.auditOrg(c, models.AuditActionCreate, models.AuditEntityWarmupRoutingRule, &ruleID, nil, map[string]string{
|
|
"name": rule.Name,
|
|
})
|
|
|
|
c.JSON(http.StatusCreated, rule)
|
|
}
|
|
|
|
// UpdateWarmupRoutingRule updates a rule by ID.
|
|
func (h *Handler) UpdateWarmupRoutingRule(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ruleID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid rule id"})
|
|
return
|
|
}
|
|
var payload warmupRoutingRulePayload
|
|
if err := c.ShouldBindJSON(&payload); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
|
|
return
|
|
}
|
|
if xerr := validateRoutingPayload(&payload); xerr != nil {
|
|
errx.JSON(c, xerr)
|
|
return
|
|
}
|
|
rule := payload.toModel(orgID, ruleID)
|
|
if err := h.WarmupRoutingRepo.Update(c.Request.Context(), rule); err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
h.auditOrg(c, models.AuditActionUpdate, models.AuditEntityWarmupRoutingRule, &ruleID, nil, map[string]string{
|
|
"name": rule.Name,
|
|
})
|
|
|
|
c.JSON(http.StatusOK, rule)
|
|
}
|
|
|
|
// DeleteWarmupRoutingRule removes a rule by ID.
|
|
func (h *Handler) DeleteWarmupRoutingRule(c *gin.Context) {
|
|
orgID, ok := requireOrgID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ruleID, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid rule id"})
|
|
return
|
|
}
|
|
if err := h.WarmupRoutingRepo.Delete(c.Request.Context(), orgID, ruleID); err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
h.auditOrg(c, models.AuditActionDelete, models.AuditEntityWarmupRoutingRule, &ruleID, nil, nil)
|
|
|
|
c.Status(http.StatusNoContent)
|
|
}
|
|
|
|
// requireOrgID resolves the caller's organization ID via the auth middleware
|
|
// helper, writing a 403 and returning ok=false when no org is set.
|
|
func requireOrgID(c *gin.Context) (uuid.UUID, bool) {
|
|
if orgID := middleware.GetOrganizationID(c); orgID != nil {
|
|
return *orgID, true
|
|
}
|
|
errx.JSON(c, errx.New(errx.Forbidden, "organization context required"))
|
|
return uuid.Nil, false
|
|
}
|