This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/ai-compliance-sdk/internal/api/handlers/portfolio_handlers.go
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

626 lines
17 KiB
Go

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,
})
}