package handlers import ( "net/http" "strconv" "github.com/breakpilot/ai-compliance-sdk/internal/portfolio" "github.com/breakpilot/ai-compliance-sdk/internal/rbac" "github.com/gin-gonic/gin" "github.com/google/uuid" ) // PortfolioHandlers handles portfolio HTTP requests type PortfolioHandlers struct { store *portfolio.Store } // NewPortfolioHandlers creates new portfolio handlers func NewPortfolioHandlers(store *portfolio.Store) *PortfolioHandlers { return &PortfolioHandlers{store: store} } // ============================================================================ // Portfolio CRUD // ============================================================================ // CreatePortfolio creates a new portfolio // POST /sdk/v1/portfolios func (h *PortfolioHandlers) CreatePortfolio(c *gin.Context) { var req portfolio.CreatePortfolioRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } tenantID := rbac.GetTenantID(c) userID := rbac.GetUserID(c) p := &portfolio.Portfolio{ TenantID: tenantID, Name: req.Name, Description: req.Description, Status: portfolio.PortfolioStatusDraft, Department: req.Department, BusinessUnit: req.BusinessUnit, Owner: req.Owner, OwnerEmail: req.OwnerEmail, Settings: req.Settings, CreatedBy: userID, } // Set default settings if !p.Settings.AutoUpdateMetrics { p.Settings.AutoUpdateMetrics = true } if err := h.store.CreatePortfolio(c.Request.Context(), p); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"portfolio": p}) } // ListPortfolios lists portfolios // GET /sdk/v1/portfolios func (h *PortfolioHandlers) ListPortfolios(c *gin.Context) { tenantID := rbac.GetTenantID(c) filters := &portfolio.PortfolioFilters{ Limit: 50, } if status := c.Query("status"); status != "" { filters.Status = portfolio.PortfolioStatus(status) } if department := c.Query("department"); department != "" { filters.Department = department } if businessUnit := c.Query("business_unit"); businessUnit != "" { filters.BusinessUnit = businessUnit } if owner := c.Query("owner"); owner != "" { filters.Owner = owner } if limit := c.Query("limit"); limit != "" { if l, err := strconv.Atoi(limit); err == nil { filters.Limit = l } } if offset := c.Query("offset"); offset != "" { if o, err := strconv.Atoi(offset); err == nil { filters.Offset = o } } portfolios, err := h.store.ListPortfolios(c.Request.Context(), tenantID, filters) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "portfolios": portfolios, "total": len(portfolios), }) } // GetPortfolio retrieves a portfolio // GET /sdk/v1/portfolios/:id func (h *PortfolioHandlers) GetPortfolio(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) return } summary, err := h.store.GetPortfolioSummary(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if summary == nil { c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"}) return } // Get stats stats, _ := h.store.GetPortfolioStats(c.Request.Context(), id) c.JSON(http.StatusOK, gin.H{ "portfolio": summary.Portfolio, "items": summary.Items, "risk_distribution": summary.RiskDistribution, "feasibility_dist": summary.FeasibilityDist, "stats": stats, }) } // UpdatePortfolio updates a portfolio // PUT /sdk/v1/portfolios/:id func (h *PortfolioHandlers) UpdatePortfolio(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) return } p, err := h.store.GetPortfolio(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if p == nil { c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"}) return } var req portfolio.UpdatePortfolioRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Name != "" { p.Name = req.Name } if req.Description != "" { p.Description = req.Description } if req.Status != "" { p.Status = req.Status } if req.Department != "" { p.Department = req.Department } if req.BusinessUnit != "" { p.BusinessUnit = req.BusinessUnit } if req.Owner != "" { p.Owner = req.Owner } if req.OwnerEmail != "" { p.OwnerEmail = req.OwnerEmail } if req.Settings != nil { p.Settings = *req.Settings } if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"portfolio": p}) } // DeletePortfolio deletes a portfolio // DELETE /sdk/v1/portfolios/:id func (h *PortfolioHandlers) DeletePortfolio(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) return } if err := h.store.DeletePortfolio(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "portfolio deleted"}) } // ============================================================================ // Portfolio Items // ============================================================================ // AddItem adds an item to a portfolio // POST /sdk/v1/portfolios/:id/items func (h *PortfolioHandlers) AddItem(c *gin.Context) { portfolioID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) return } var req portfolio.AddItemRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } userID := rbac.GetUserID(c) item := &portfolio.PortfolioItem{ PortfolioID: portfolioID, ItemType: req.ItemType, ItemID: req.ItemID, Tags: req.Tags, Notes: req.Notes, AddedBy: userID, } if err := h.store.AddItem(c.Request.Context(), item); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusCreated, gin.H{"item": item}) } // ListItems lists items in a portfolio // GET /sdk/v1/portfolios/:id/items func (h *PortfolioHandlers) ListItems(c *gin.Context) { portfolioID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) return } var itemType *portfolio.ItemType if t := c.Query("type"); t != "" { it := portfolio.ItemType(t) itemType = &it } items, err := h.store.ListItems(c.Request.Context(), portfolioID, itemType) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "items": items, "total": len(items), }) } // BulkAddItems adds multiple items to a portfolio // POST /sdk/v1/portfolios/:id/items/bulk func (h *PortfolioHandlers) BulkAddItems(c *gin.Context) { portfolioID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) return } var req portfolio.BulkAddItemsRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } userID := rbac.GetUserID(c) // Convert AddItemRequest to PortfolioItem items := make([]portfolio.PortfolioItem, len(req.Items)) for i, r := range req.Items { items[i] = portfolio.PortfolioItem{ ItemType: r.ItemType, ItemID: r.ItemID, Tags: r.Tags, Notes: r.Notes, } } result, err := h.store.BulkAddItems(c.Request.Context(), portfolioID, items, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, result) } // RemoveItem removes an item from a portfolio // DELETE /sdk/v1/portfolios/:id/items/:itemId func (h *PortfolioHandlers) RemoveItem(c *gin.Context) { itemID, err := uuid.Parse(c.Param("itemId")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid item ID"}) return } if err := h.store.RemoveItem(c.Request.Context(), itemID); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "item removed"}) } // ReorderItems updates the order of items // PUT /sdk/v1/portfolios/:id/items/order func (h *PortfolioHandlers) ReorderItems(c *gin.Context) { portfolioID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) return } var req struct { ItemIDs []uuid.UUID `json:"item_ids"` } if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if err := h.store.UpdateItemOrder(c.Request.Context(), portfolioID, req.ItemIDs); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{"message": "items reordered"}) } // ============================================================================ // Merge Operations // ============================================================================ // MergePortfolios merges two portfolios // POST /sdk/v1/portfolios/merge func (h *PortfolioHandlers) MergePortfolios(c *gin.Context) { var req portfolio.MergeRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } // Validate portfolios exist source, err := h.store.GetPortfolio(c.Request.Context(), req.SourcePortfolioID) if err != nil || source == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "source portfolio not found"}) return } target, err := h.store.GetPortfolio(c.Request.Context(), req.TargetPortfolioID) if err != nil || target == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "target portfolio not found"}) return } // Set defaults if req.Strategy == "" { req.Strategy = portfolio.MergeStrategyUnion } userID := rbac.GetUserID(c) result, err := h.store.MergePortfolios(c.Request.Context(), &req, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "message": "portfolios merged", "result": result, }) } // ============================================================================ // Statistics & Reports // ============================================================================ // GetPortfolioStats returns statistics for a portfolio // GET /sdk/v1/portfolios/:id/stats func (h *PortfolioHandlers) GetPortfolioStats(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) return } stats, err := h.store.GetPortfolioStats(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, stats) } // GetPortfolioActivity returns recent activity for a portfolio // GET /sdk/v1/portfolios/:id/activity func (h *PortfolioHandlers) GetPortfolioActivity(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) return } limit := 20 if l := c.Query("limit"); l != "" { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 { limit = parsed } } activities, err := h.store.GetRecentActivity(c.Request.Context(), id, limit) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "activities": activities, "total": len(activities), }) } // ComparePortfolios compares multiple portfolios // POST /sdk/v1/portfolios/compare func (h *PortfolioHandlers) ComparePortfolios(c *gin.Context) { var req portfolio.ComparePortfoliosRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if len(req.PortfolioIDs) < 2 { c.JSON(http.StatusBadRequest, gin.H{"error": "at least 2 portfolios required for comparison"}) return } if len(req.PortfolioIDs) > 5 { c.JSON(http.StatusBadRequest, gin.H{"error": "maximum 5 portfolios can be compared"}) return } // Get all portfolios var portfolios []portfolio.Portfolio comparison := portfolio.PortfolioComparison{ RiskScores: make(map[string]float64), ComplianceScores: make(map[string]float64), ItemCounts: make(map[string]int), UniqueItems: make(map[string][]uuid.UUID), } allItems := make(map[uuid.UUID][]string) // item_id -> portfolio_ids for _, id := range req.PortfolioIDs { p, err := h.store.GetPortfolio(c.Request.Context(), id) if err != nil || p == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio not found: " + id.String()}) return } portfolios = append(portfolios, *p) idStr := id.String() comparison.RiskScores[idStr] = p.AvgRiskScore comparison.ComplianceScores[idStr] = p.ComplianceScore comparison.ItemCounts[idStr] = p.TotalAssessments + p.TotalRoadmaps + p.TotalWorkshops // Get items for comparison items, _ := h.store.ListItems(c.Request.Context(), id, nil) for _, item := range items { allItems[item.ItemID] = append(allItems[item.ItemID], idStr) } } // Find common and unique items for itemID, portfolioIDs := range allItems { if len(portfolioIDs) > 1 { comparison.CommonItems = append(comparison.CommonItems, itemID) } else { pid := portfolioIDs[0] comparison.UniqueItems[pid] = append(comparison.UniqueItems[pid], itemID) } } c.JSON(http.StatusOK, portfolio.ComparePortfoliosResponse{ Portfolios: portfolios, Comparison: comparison, }) } // RecalculateMetrics manually recalculates portfolio metrics // POST /sdk/v1/portfolios/:id/recalculate func (h *PortfolioHandlers) RecalculateMetrics(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) return } if err := h.store.RecalculateMetrics(c.Request.Context(), id); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Get updated portfolio p, _ := h.store.GetPortfolio(c.Request.Context(), id) c.JSON(http.StatusOK, gin.H{ "message": "metrics recalculated", "portfolio": p, }) } // ============================================================================ // Approval Workflow // ============================================================================ // ApprovePortfolio approves a portfolio // POST /sdk/v1/portfolios/:id/approve func (h *PortfolioHandlers) ApprovePortfolio(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) return } p, err := h.store.GetPortfolio(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if p == nil { c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"}) return } if p.Status != portfolio.PortfolioStatusReview { c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio must be in REVIEW status to approve"}) return } userID := rbac.GetUserID(c) now := c.Request.Context().Value("now") if now == nil { t := p.UpdatedAt p.ApprovedAt = &t } p.ApprovedBy = &userID p.Status = portfolio.PortfolioStatusApproved if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "message": "portfolio approved", "portfolio": p, }) } // SubmitForReview submits a portfolio for review // POST /sdk/v1/portfolios/:id/submit-review func (h *PortfolioHandlers) SubmitForReview(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid portfolio ID"}) return } p, err := h.store.GetPortfolio(c.Request.Context(), id) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } if p == nil { c.JSON(http.StatusNotFound, gin.H{"error": "portfolio not found"}) return } if p.Status != portfolio.PortfolioStatusDraft && p.Status != portfolio.PortfolioStatusActive { c.JSON(http.StatusBadRequest, gin.H{"error": "portfolio must be in DRAFT or ACTIVE status to submit for review"}) return } p.Status = portfolio.PortfolioStatusReview if err := h.store.UpdatePortfolio(c.Request.Context(), p); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "message": "portfolio submitted for review", "portfolio": p, }) }