package handlers import ( "net/http" "time" "github.com/gin-gonic/gin" "switchboard-core/models" "switchboard-core/store" ) // UsageHandler manages usage tracking and pricing endpoints. type UsageHandler struct { stores store.Stores } // NewUsageHandler creates a usage handler. func NewUsageHandler(s store.Stores) *UsageHandler { return &UsageHandler{stores: s} } // ── Admin: Aggregated Usage ──────────────── // GET /admin/usage?since=...&until=...&group_by=model|user|day|provider func (h *UsageHandler) AdminUsage(c *gin.Context) { opts := h.parseQueryOptions(c) opts.ExcludeBYOK = true // Admin views exclude personal BYOK results, err := h.stores.Usage.QueryByModel(c.Request.Context(), opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"}) return } totals, err := h.stores.Usage.GetTotals(c.Request.Context(), opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get totals"}) return } c.JSON(http.StatusOK, gin.H{ "totals": totals, "results": results, }) } // ── Admin: Per-User Usage ────────────────── // GET /admin/usage/users/:id func (h *UsageHandler) AdminUserUsage(c *gin.Context) { userID := c.Param("id") opts := h.parseQueryOptions(c) opts.ExcludeBYOK = true results, err := h.stores.Usage.QueryByUser(c.Request.Context(), userID, opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query user usage"}) return } c.JSON(http.StatusOK, gin.H{"results": results}) } // ── Admin: Per-Team Usage ────────────────── // GET /admin/usage/teams/:id func (h *UsageHandler) AdminTeamUsage(c *gin.Context) { teamID := c.Param("id") opts := h.parseQueryOptions(c) opts.ExcludeBYOK = true results, err := h.stores.Usage.QueryByTeam(c.Request.Context(), teamID, opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"}) return } c.JSON(http.StatusOK, gin.H{"results": results}) } // ── User: Personal Usage ─────────────────── // GET /usage func (h *UsageHandler) PersonalUsage(c *gin.Context) { userID := getUserID(c) opts := h.parseQueryOptions(c) totals, err := h.stores.Usage.GetPersonalTotals(c.Request.Context(), userID, opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get usage"}) return } results, err := h.stores.Usage.QueryByUserPersonal(c.Request.Context(), userID, opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query usage"}) return } c.JSON(http.StatusOK, gin.H{ "totals": totals, "results": results, }) } // ── Team Admin: Team Provider Usage ──────── // GET /teams/:teamId/usage // // Shows usage against providers owned by this team. Only team admins // (enforced by RequireTeamAdmin middleware) can see this view. func (h *UsageHandler) TeamUsage(c *gin.Context) { teamID := c.Param("teamId") opts := h.parseQueryOptions(c) totals, err := h.stores.Usage.GetTeamProviderTotals(c.Request.Context(), teamID, opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to get team usage"}) return } results, err := h.stores.Usage.QueryByTeamProviders(c.Request.Context(), teamID, opts) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to query team usage"}) return } c.JSON(http.StatusOK, gin.H{ "totals": totals, "results": results, }) } // ── Admin: List Pricing ──────────────────── // GET /admin/pricing func (h *UsageHandler) ListPricing(c *gin.Context) { entries, err := h.stores.Pricing.List(c.Request.Context()) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list pricing"}) return } if entries == nil { entries = []models.PricingEntry{} } c.JSON(http.StatusOK, entries) } // ── Admin: Upsert Pricing ────────────────── // PUT /admin/pricing func (h *UsageHandler) UpsertPricing(c *gin.Context) { var req models.PricingEntry if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate provider is admin-managed (global or team), not personal BYOK pc, err := h.stores.Providers.GetByID(c.Request.Context(), req.ProviderConfigID) if err != nil || pc == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "provider config not found"}) return } if pc.Scope == "personal" { c.JSON(http.StatusForbidden, gin.H{"error": "cannot set pricing for personal BYOK providers"}) return } userID := getUserID(c) req.Source = "manual" req.UpdatedBy = &userID if err := h.stores.Pricing.Upsert(c.Request.Context(), &req); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save pricing"}) return } c.JSON(http.StatusOK, gin.H{"message": "pricing saved"}) } // ── Admin: Delete Pricing ────────────────── // DELETE /admin/pricing/:provider/:model func (h *UsageHandler) DeletePricing(c *gin.Context) { providerConfigID := c.Param("provider") modelID := c.Param("model") if err := h.stores.Pricing.Delete(c.Request.Context(), providerConfigID, modelID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete pricing"}) return } c.JSON(http.StatusOK, gin.H{"message": "pricing deleted"}) } // ── Helpers ──────────────────────────────── func (h *UsageHandler) parseQueryOptions(c *gin.Context) store.UsageQueryOptions { opts := store.UsageQueryOptions{ GroupBy: c.DefaultQuery("group_by", "model"), Limit: 100, } if since := c.Query("since"); since != "" { if t, err := time.Parse(time.RFC3339, since); err == nil { opts.Since = &t } } if until := c.Query("until"); until != "" { if t, err := time.Parse(time.RFC3339, until); err == nil { opts.Until = &t } } // Convenience shorthand: ?period=7d|30d|90d if period := c.Query("period"); period != "" && opts.Since == nil { var dur time.Duration switch period { case "7d": dur = 7 * 24 * time.Hour case "30d": dur = 30 * 24 * time.Hour case "90d": dur = 90 * 24 * time.Hour } if dur > 0 { t := time.Now().Add(-dur) opts.Since = &t } } return opts }