mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-03 16:02:02 +00:00
537 lines
14 KiB
Go
537 lines
14 KiB
Go
package handler
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/warmbly/warmbly/internal/api/middleware"
|
|
"github.com/warmbly/warmbly/internal/config"
|
|
"github.com/warmbly/warmbly/internal/errx"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// GetWarmupAnalytics gets warmup statistics for the selected organization.
|
|
// GET /analytics/warmup
|
|
func (h *Handler) GetWarmupAnalytics(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
// Parse optional email_id filter
|
|
var emailAccountID *uuid.UUID
|
|
if emailIDStr := c.Query("email_id"); emailIDStr != "" {
|
|
if id, err := uuid.Parse(emailIDStr); err == nil {
|
|
emailAccountID = &id
|
|
}
|
|
}
|
|
|
|
// Parse date range (required)
|
|
fromStr := c.Query("from")
|
|
toStr := c.Query("to")
|
|
|
|
if fromStr == "" || toStr == "" {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "from and to date parameters are required"))
|
|
return
|
|
}
|
|
|
|
from, err := time.Parse("2006-01-02", fromStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "Invalid from date format (expected YYYY-MM-DD)"))
|
|
return
|
|
}
|
|
|
|
to, err := time.Parse("2006-01-02", toStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "Invalid to date format (expected YYYY-MM-DD)"))
|
|
return
|
|
}
|
|
|
|
analytics, xerr := h.AnalyticsService.GetWarmupAnalytics(c.Request.Context(), *orgID, emailAccountID, from, to)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, analytics)
|
|
}
|
|
|
|
// GetWarmupPlacement reports where warmup mail landed (inbox, category tabs,
|
|
// spam) per day and per recipient provider, for one mailbox or the workspace.
|
|
// GET /analytics/warmup/placement
|
|
func (h *Handler) GetWarmupPlacement(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
var emailAccountID *uuid.UUID
|
|
if raw := c.Query("email_id"); raw != "" {
|
|
id, err := uuid.Parse(raw)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "email_id must be a UUID"))
|
|
return
|
|
}
|
|
emailAccountID = &id
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
to := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
|
if raw := c.Query("to"); raw != "" {
|
|
d, err := time.Parse("2006-01-02", raw)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "Invalid to date format (expected YYYY-MM-DD)"))
|
|
return
|
|
}
|
|
to = d
|
|
}
|
|
// The default window is the 30 days ending on to, whichever to is.
|
|
from := to.AddDate(0, 0, -29)
|
|
if raw := c.Query("from"); raw != "" {
|
|
d, err := time.Parse("2006-01-02", raw)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "Invalid from date format (expected YYYY-MM-DD)"))
|
|
return
|
|
}
|
|
from = d
|
|
}
|
|
|
|
report, xerr := h.AnalyticsService.GetWarmupPlacement(c.Request.Context(), *orgID, emailAccountID, from, to)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, report)
|
|
}
|
|
|
|
// GetCampaignAnalytics gets analytics for a specific campaign
|
|
// GET /analytics/campaigns/:id[?from=YYYY-MM-DD&to=YYYY-MM-DD]
|
|
func (h *Handler) GetCampaignAnalytics(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
campaignIDStr := c.Param("id")
|
|
campaignID, err := uuid.Parse(campaignIDStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
period, xerr := optionalDayRange(c)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
analytics, xerr := h.AnalyticsService.GetCampaignAnalytics(c.Request.Context(), *orgID, campaignID, period)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, analytics)
|
|
}
|
|
|
|
// optionalDayRange reads from and to as whole days, both or neither; neither
|
|
// is nil, which callers read as all time.
|
|
func optionalDayRange(c *gin.Context) (*models.DateRange, *errx.Error) {
|
|
period, err := models.ParseDayRange(c.Query("from"), c.Query("to"))
|
|
if err != nil {
|
|
return nil, errx.New(errx.BadRequest, err.Error())
|
|
}
|
|
return period, nil
|
|
}
|
|
|
|
// GetCampaignDailyStats gets daily statistics for a campaign
|
|
// GET /analytics/campaigns/:id/daily
|
|
func (h *Handler) GetCampaignDailyStats(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
campaignIDStr := c.Param("id")
|
|
campaignID, err := uuid.Parse(campaignIDStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
// Parse date range
|
|
fromStr := c.Query("from")
|
|
toStr := c.Query("to")
|
|
|
|
if fromStr == "" || toStr == "" {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "from and to date parameters are required"))
|
|
return
|
|
}
|
|
|
|
from, err := time.Parse("2006-01-02", fromStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "Invalid from date format (expected YYYY-MM-DD)"))
|
|
return
|
|
}
|
|
|
|
to, err := time.Parse("2006-01-02", toStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "Invalid to date format (expected YYYY-MM-DD)"))
|
|
return
|
|
}
|
|
|
|
stats, xerr := h.AnalyticsService.GetCampaignDailyStats(c.Request.Context(), *orgID, campaignID, from, to)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": stats})
|
|
}
|
|
|
|
// GetAllAccountStatuses lists email account statuses, one bounded page at a
|
|
// time. A page view asks only for the mailbox ids it shows (email_ids);
|
|
// otherwise the opaque cursor walks the whole inventory. The per-mailbox reads
|
|
// are batched across the page, so a page's cost does not grow with the total
|
|
// inventory, and the overflow beyond the old 1000-row cap is reachable through
|
|
// next_cursor instead of being silently dropped.
|
|
// GET /analytics/accounts
|
|
func (h *Handler) GetAllAccountStatuses(c *gin.Context) {
|
|
// Account lookups are org-scoped (emailRepo.Search filters on
|
|
// organization_id), so pass the selected org, not the user id.
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
// email_ids scopes the page to a visible set; invalid or over-limit is a
|
|
// 400, never a silently truncated or ignored filter.
|
|
var emailIDs []uuid.UUID
|
|
if raw := strings.TrimSpace(c.Query("email_ids")); raw != "" {
|
|
parts := strings.Split(raw, ",")
|
|
if len(parts) > config.AccountStatusMaxIDs {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "too many email_ids"))
|
|
return
|
|
}
|
|
emailIDs = make([]uuid.UUID, 0, len(parts))
|
|
for _, p := range parts {
|
|
id, err := uuid.Parse(strings.TrimSpace(p))
|
|
if err != nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid email_ids"))
|
|
return
|
|
}
|
|
emailIDs = append(emailIDs, id)
|
|
}
|
|
}
|
|
|
|
limit := config.AccountStatusLimitDefault
|
|
if raw := c.Query("limit"); raw != "" {
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n < 1 || n > config.AccountStatusLimitMax {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "invalid limit"))
|
|
return
|
|
}
|
|
limit = n
|
|
}
|
|
|
|
result, xerr := h.AnalyticsService.GetAccountStatusesPage(c.Request.Context(), *orgID, emailIDs, c.Query("cursor"), int32(limit))
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": result.Data, "pagination": result.Pagination})
|
|
}
|
|
|
|
// GetAccountStatus gets status of a specific email account
|
|
// GET /analytics/accounts/:id
|
|
func (h *Handler) GetAccountStatus(c *gin.Context) {
|
|
// Org-scoped like the list endpoint above.
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.JSON(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
accountIDStr := c.Param("id")
|
|
accountID, err := uuid.Parse(accountIDStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
status, xerr := h.AnalyticsService.GetAccountStatusDetail(c.Request.Context(), *orgID, accountID)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, status)
|
|
}
|
|
|
|
// GetUsageOverview gets usage overview for the selected organization.
|
|
// GET /analytics/usage
|
|
func (h *Handler) GetUsageOverview(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
userIDStr := middleware.GetUserID(c)
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrAuth)
|
|
return
|
|
}
|
|
|
|
period := c.DefaultQuery("period", "day")
|
|
if period != "day" && period != "week" && period != "month" {
|
|
period = "day"
|
|
}
|
|
|
|
overview, xerr := h.AnalyticsService.GetUsageOverview(c.Request.Context(), *orgID, userID, period)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, overview)
|
|
}
|
|
|
|
// GetRealtimeInfo returns WebSocket connection info
|
|
// GET /realtime/info
|
|
func (h *Handler) GetRealtimeInfo(c *gin.Context) {
|
|
userIDStr := middleware.GetUserID(c)
|
|
userID, err := uuid.Parse(userIDStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrAuth)
|
|
return
|
|
}
|
|
|
|
// Get WebSocket host from request or config
|
|
wsHost := c.Request.Header.Get("X-Forwarded-Host")
|
|
if wsHost == "" {
|
|
wsHost = c.Request.Host
|
|
}
|
|
wsHost = "wss://" + wsHost
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"websocket_url": wsHost + "/socket",
|
|
"topics": []string{
|
|
"user:" + userID.String(),
|
|
"campaign:*",
|
|
"account:*",
|
|
},
|
|
})
|
|
}
|
|
|
|
// GetDashboardAnalytics returns main dashboard analytics overview
|
|
// GET /analytics/dashboard?period=7d
|
|
func (h *Handler) GetDashboardAnalytics(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
// Parse period (7d, 30d, 90d)
|
|
period := c.DefaultQuery("period", "7d")
|
|
if period != "7d" && period != "30d" && period != "90d" {
|
|
period = "7d"
|
|
}
|
|
|
|
analytics, xerr := h.AnalyticsService.GetDashboardAnalytics(c.Request.Context(), *orgID, period)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
// The sidebar meter's denominator: the mailboxes' day under the
|
|
// scheduler's own clamps, not their caps added up. Best effort; the
|
|
// dashboard still renders without it.
|
|
if h.CampaignService != nil {
|
|
if capacity, cerr := h.CampaignService.WorkspaceCapacity(c.Request.Context(), *orgID); cerr == nil {
|
|
analytics.CapacityToday = capacity
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, analytics)
|
|
}
|
|
|
|
// GetCampaignHourlyStats returns hourly statistics for a campaign on a specific date
|
|
// GET /analytics/campaigns/:id/hourly?date=2024-01-15
|
|
func (h *Handler) GetCampaignHourlyStats(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
campaignIDStr := c.Param("id")
|
|
campaignID, err := uuid.Parse(campaignIDStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.ErrNotFound)
|
|
return
|
|
}
|
|
|
|
// Parse date
|
|
dateStr := c.Query("date")
|
|
if dateStr == "" {
|
|
dateStr = time.Now().Format("2006-01-02")
|
|
}
|
|
|
|
date, err := time.Parse("2006-01-02", dateStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "Invalid date format (expected YYYY-MM-DD)"))
|
|
return
|
|
}
|
|
|
|
stats, xerr := h.AnalyticsService.GetCampaignHourlyStats(c.Request.Context(), *orgID, campaignID, date)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"data": stats, "date": dateStr})
|
|
}
|
|
|
|
// CompareCampaigns returns comparison statistics for multiple campaigns
|
|
// GET /analytics/campaigns/compare?ids=uuid1,uuid2&from=2024-01-01&to=2024-01-31
|
|
func (h *Handler) CompareCampaigns(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
// Parse campaign IDs
|
|
idsStr := c.Query("ids")
|
|
if idsStr == "" {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "ids parameter is required"))
|
|
return
|
|
}
|
|
|
|
var campaignIDs []uuid.UUID
|
|
for _, idStr := range splitAndTrim(idsStr) {
|
|
id, err := uuid.Parse(idStr)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
campaignIDs = append(campaignIDs, id)
|
|
}
|
|
|
|
if len(campaignIDs) == 0 {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "at least one valid campaign ID is required"))
|
|
return
|
|
}
|
|
|
|
// Limit to 10 campaigns
|
|
if len(campaignIDs) > 10 {
|
|
campaignIDs = campaignIDs[:10]
|
|
}
|
|
|
|
// Parse date range
|
|
fromStr := c.Query("from")
|
|
toStr := c.Query("to")
|
|
|
|
if fromStr == "" || toStr == "" {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "from and to date parameters are required"))
|
|
return
|
|
}
|
|
|
|
from, err := time.Parse("2006-01-02", fromStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "Invalid from date format (expected YYYY-MM-DD)"))
|
|
return
|
|
}
|
|
|
|
to, err := time.Parse("2006-01-02", toStr)
|
|
if err != nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "Invalid to date format (expected YYYY-MM-DD)"))
|
|
return
|
|
}
|
|
|
|
comparison, xerr := h.AnalyticsService.CompareCampaigns(c.Request.Context(), *orgID, campaignIDs, from, to)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, comparison)
|
|
}
|
|
|
|
// splitAndTrim splits a comma-separated string and trims whitespace
|
|
func splitAndTrim(s string) []string {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
parts := make([]string, 0)
|
|
for _, part := range split(s, ',') {
|
|
trimmed := trim(part)
|
|
if trimmed != "" {
|
|
parts = append(parts, trimmed)
|
|
}
|
|
}
|
|
return parts
|
|
}
|
|
|
|
func split(s string, sep rune) []string {
|
|
var result []string
|
|
current := ""
|
|
for _, c := range s {
|
|
if c == sep {
|
|
result = append(result, current)
|
|
current = ""
|
|
} else {
|
|
current += string(c)
|
|
}
|
|
}
|
|
result = append(result, current)
|
|
return result
|
|
}
|
|
|
|
func trim(s string) string {
|
|
start := 0
|
|
end := len(s)
|
|
for start < end && (s[start] == ' ' || s[start] == '\t') {
|
|
start++
|
|
}
|
|
for end > start && (s[end-1] == ' ' || s[end-1] == '\t') {
|
|
end--
|
|
}
|
|
return s[start:end]
|
|
}
|
|
|
|
// GetDirectMailAnalytics returns the hand-written-mail overview: volume and
|
|
// replies from the synced mailbox, plus opens and clicks for the mailboxes that
|
|
// opted into tracking them.
|
|
// GET /analytics/direct?period=7d
|
|
func (h *Handler) GetDirectMailAnalytics(c *gin.Context) {
|
|
orgID := middleware.GetOrganizationID(c)
|
|
if orgID == nil {
|
|
errx.Handle(c, errx.New(errx.BadRequest, "no organization selected"))
|
|
return
|
|
}
|
|
|
|
period := c.DefaultQuery("period", "7d")
|
|
if period != "7d" && period != "30d" && period != "90d" {
|
|
period = "7d"
|
|
}
|
|
|
|
analytics, xerr := h.AnalyticsService.GetDirectMailAnalytics(c.Request.Context(), *orgID, period)
|
|
if xerr != nil {
|
|
errx.Handle(c, xerr)
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, analytics)
|
|
}
|