package handlers import ( "context" // Ensure context is imported "errors" // Added for errors.As "fmt" "net/http" "strconv" "git.linuxforward.com/byop/byop-engine/dbstore" "git.linuxforward.com/byop/byop-engine/models" "git.linuxforward.com/byop/byop-engine/services" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" ) // AppsHandler handles app-related operations and contains integrated service logic type AppsHandler struct { store *dbstore.SQLiteStore entry *logrus.Entry previewService services.PreviewService } // NewAppsHandler creates a new AppsHandler func NewAppsHandler(store *dbstore.SQLiteStore, previewService services.PreviewService) *AppsHandler { return &AppsHandler{ store: store, entry: logrus.WithField("component", "AppsHandler"), previewService: previewService, } } // ListApps returns all apps with optional filtering func (h *AppsHandler) ListApps(c *gin.Context) { ctx := c.Request.Context() // Get context filter := make(map[string]interface{}) // Attempt to bind query parameters, but allow empty filters if err := c.ShouldBindQuery(&filter); err != nil && len(filter) > 0 { models.RespondWithError(c, models.NewErrValidation("Invalid query parameters", nil, err)) return } // Get apps directly from store apps, err := h.store.GetAllApps(ctx) // Pass context if err != nil { models.RespondWithError(c, err) // Pass db error directly return } // If empty, return an empty list if len(apps) == 0 { c.JSON(http.StatusOK, []*models.App{}) // Return empty slice of pointers return } c.JSON(http.StatusOK, apps) } // CreateApp creates a new deployment app func (h *AppsHandler) CreateApp(c *gin.Context) { ctx := c.Request.Context() // Get context app := &models.App{} if err := c.ShouldBindJSON(&app); err != nil { h.entry.WithField("error", err).Error("Failed to bind JSON to App struct") models.RespondWithError(c, models.NewErrValidation("Invalid request body", nil, err)) return } // Get the user ID from the context (set by auth middleware) userIDInterface, exists := c.Get("user_id") if !exists { models.RespondWithError(c, models.NewErrUnauthorized("User ID not found in context", nil)) return } // Convert user_id to int - it might be a string from JWT var userID int switch v := userIDInterface.(type) { case string: if parsedID, err := strconv.Atoi(v); err == nil { userID = parsedID } else { h.entry.Warnf("Failed to parse user_id string '%s' to int, defaulting to 1. Error: %v", v, err) userID = 1 } case int: userID = v case int64: userID = int(v) default: h.entry.Warnf("User_id in context is of unexpected type %T, defaulting to 1.", v) userID = 1 } // Set the user ID on the app app.UserID = userID h.entry.WithField("app", app).Info("JSON binding successful, starting validation") // Validate app configuration if err := h.validateAppConfig(ctx, app.Components); err != nil { // Pass context h.entry.WithField("error", err).Error("App configuration validation failed") // Check if the error from validateAppConfig is already a CustomError var customErr models.CustomError if errors.As(err, &customErr) { models.RespondWithError(c, customErr) } else { models.RespondWithError(c, models.NewErrValidation(fmt.Sprintf("Invalid app configuration: %v", err), nil, err)) } return } h.entry.Info("App configuration validation passed") // Set initial status app.Status = "building" h.entry.WithField("app", app).Info("About to create app in database") // Create the app id, err := h.store.CreateApp(ctx, app) // Pass context if err != nil { h.entry.WithField("error", err).Error("Failed to create app in database") models.RespondWithError(c, err) // Pass db error directly return } app.ID = id // Automatically create preview - this happens async h.entry.WithField("app_id", app.ID).Info("Starting automatic preview creation") go h.createAppPreviewAsync(context.Background(), id) // Use background context for async operation c.JSON(http.StatusCreated, app) } // GetApp returns a specific app func (h *AppsHandler) GetApp(c *gin.Context) { ctx := c.Request.Context() // Get context idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { models.RespondWithError(c, models.NewErrValidation("Invalid app ID format", nil, err)) return } // Get app directly from store app, err := h.store.GetAppByID(ctx, int(id)) // Pass context and cast id if err != nil { models.RespondWithError(c, err) // Pass db error directly (handles NotFound) return } // GetAppByID now returns models.NewErrNotFound, so this check might be redundant if RespondWithError handles nil correctly // However, GetAppByID returns (*models.App, error), so if err is nil, app could still be nil (though current db logic prevents this for NotFound) if app == nil && err == nil { // Explicitly handle if GetAppByID somehow returns nil, nil without specific error models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil)) return } c.JSON(http.StatusOK, app) } // Updated UpdateApp method func (h *AppsHandler) UpdateApp(c *gin.Context) { ctx := c.Request.Context() // Get context idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { models.RespondWithError(c, models.NewErrValidation("Invalid app ID format", nil, err)) return } updatedApp := &models.App{} if err := c.ShouldBindJSON(updatedApp); err != nil { models.RespondWithError(c, models.NewErrValidation("Invalid request body", nil, err)) return } // Ensure the ID matches the URL parameter updatedApp.ID = int(id) // Validate app data if err := h.validateAppConfig(ctx, updatedApp.Components); err != nil { // Pass context var customErr models.CustomError if errors.As(err, &customErr) { models.RespondWithError(c, customErr) } else { models.RespondWithError(c, models.NewErrValidation(fmt.Sprintf("Invalid app data: %v", err), nil, err)) } return } // Check if app exists (though UpdateApp in store will also do this and return ErrNotFound) // This is more for a clear early exit if needed, but store.UpdateApp handles it. // For consistency, we can rely on store.UpdateApp's error. // Validate all components exist and are valid for _, componentID := range updatedApp.Components { component, err := h.store.GetComponentByID(ctx, componentID) // Pass context if err != nil { models.RespondWithError(c, err) // Pass db error directly (handles NotFound) return } // GetComponentByID now returns models.NewErrNotFound, so this check might be redundant if component == nil && err == nil { // Explicitly handle if GetComponentByID somehow returns nil, nil models.RespondWithError(c, models.NewErrValidation(fmt.Sprintf("Component %d not found during app update validation", componentID), nil, nil)) return } if component.Status != "valid" { models.RespondWithError(c, models.NewErrValidation(fmt.Sprintf("Component %d is not valid (status: %s)", componentID, component.Status), nil, nil)) return } } // Set status to building (will be updated by preview creation) updatedApp.Status = "building" // Update the app if err := h.store.UpdateApp(ctx, updatedApp); err != nil { // Pass context models.RespondWithError(c, err) // Pass db error directly (handles NotFound) return } // Stop any existing previews for this app go h.stopExistingPreviews(context.Background(), updatedApp.ID) // Use background context // Automatically create new preview h.entry.WithField("app_id", updatedApp.ID).Info("Starting automatic preview creation after update") go h.createAppPreviewAsync(context.Background(), updatedApp.ID) // Use background context c.JSON(http.StatusOK, updatedApp) } // DeleteApp deletes an app func (h *AppsHandler) DeleteApp(c *gin.Context) { ctx := c.Request.Context() // Get context idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { models.RespondWithError(c, models.NewErrValidation("Invalid app ID format", nil, err)) return } // Call the store delete method if err := h.store.DeleteApp(ctx, int(id)); err != nil { // Pass context models.RespondWithError(c, err) // Pass db error directly (handles NotFound, Conflict) return } c.JSON(http.StatusOK, gin.H{"message": "App deleted successfully"}) } // GetAppDeployments returns all deployments for an app func (h *AppsHandler) GetAppDeployments(c *gin.Context) { ctx := c.Request.Context() // Get context idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { models.RespondWithError(c, models.NewErrValidation("Invalid app ID format", nil, err)) return } // Check if app exists first app, err := h.store.GetAppByID(ctx, int(id)) // Pass context if err != nil { models.RespondWithError(c, err) // Pass db error directly (handles NotFound) return } if app == nil && err == nil { // Should be caught by GetAppByID's error handling models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil)) return } // Get deployments for this app deployments, err := h.store.GetDeploymentsByAppID(ctx, int(id)) // Pass context if err != nil { models.RespondWithError(c, err) // Pass db error directly return } c.JSON(http.StatusOK, deployments) } // GetAppByVersion handles retrieval of an app by name and version func (h *AppsHandler) GetAppByVersion(c *gin.Context) { ctx := c.Request.Context() // Get context name := c.Query("name") version := c.Query("version") if name == "" || version == "" { models.RespondWithError(c, models.NewErrValidation("Both name and version query parameters are required", nil, nil)) return } // Get app by name and version directly from store app, err := h.getAppByNameAndVersion(ctx, name, version) // Pass context if err != nil { var customErr models.CustomError if errors.As(err, &customErr) { models.RespondWithError(c, customErr) } else { models.RespondWithError(c, models.NewErrInternalServer(fmt.Sprintf("Failed to fetch app by name '%s' and version '%s'", name, version), err)) } return } if app == nil { models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with name '%s' and version '%s' not found", name, version), nil)) return } c.JSON(http.StatusOK, app) } // validateAppConfig checks if the app configuration is valid func (h *AppsHandler) validateAppConfig(ctx context.Context, componentIds []int) error { // Added context if len(componentIds) == 0 { return models.NewErrValidation("App must have at least one component", nil, nil) // Return custom error } for _, compId := range componentIds { if compId == 0 { return models.NewErrValidation("Component ID cannot be zero", nil, nil) // Return custom error } comp, err := h.store.GetComponentByID(ctx, compId) // Pass context if err != nil { return err } if comp == nil && err == nil { return models.NewErrValidation(fmt.Sprintf("Component with ID %d does not exist (validation check)", compId), nil, nil) } if comp.Name == "" { return models.NewErrValidation(fmt.Sprintf("Component with ID %d has an empty name", compId), nil, nil) // Return custom error } if comp.Type == "" { return models.NewErrValidation(fmt.Sprintf("Component with ID %d has an empty type", compId), nil, nil) // Return custom error } } return nil } // getAppByNameAndVersion retrieves an app by name and version from the store func (h *AppsHandler) getAppByNameAndVersion(ctx context.Context, name, version string) (*models.App, error) { // Added context apps, err := h.store.GetAllApps(ctx) // Pass context if err != nil { return nil, err } for _, app := range apps { if app.Name == name && app.Description == version { // Using description as version for now return app, nil } } return nil, models.NewErrNotFound(fmt.Sprintf("App with name '%s' and version '%s' not found (helper)", name, version), nil) // Return custom error } // createAppPreviewAsync creates a preview automatically and updates app status func (h *AppsHandler) createAppPreviewAsync(ctx context.Context, appId int) { // Added context preview, err := h.previewService.CreatePreview(ctx, appId) // Pass context if err != nil { h.entry.WithField("app_id", appId).Errorf("Failed to create preview: %v", err) if updateErr := h.store.UpdateAppStatus(ctx, appId, "failed", fmt.Sprintf("Failed to create preview: %v", err)); updateErr != nil { h.entry.WithField("app_id", appId).Errorf("Additionally failed to update app status after preview failure: %v", updateErr) } return } if err := h.store.UpdateAppPreview(ctx, appId, preview.ID, preview.URL); err != nil { // Pass context h.entry.WithField("app_id", appId).Errorf("Failed to update app with preview info: %v", err) } h.entry.WithField("app_id", appId).WithField("preview_id", preview.ID).Info("Automatic preview creation initiated") } // stopExistingPreviews stops any running previews for an app func (h *AppsHandler) stopExistingPreviews(ctx context.Context, appID int) { // Added context previews, err := h.store.GetPreviewsByAppID(ctx, appID) // Pass context if err != nil { h.entry.WithField("app_id", appID).Errorf("Failed to get existing previews: %v", err) return } for _, preview := range previews { if preview.Status == "running" { if err := h.previewService.StopPreview(ctx, preview.ID); err != nil { // Pass context h.entry.WithField("preview_id", preview.ID).Errorf("Failed to stop existing preview: %v", err) } } } } // CreateAppPreview creates a new preview for an app func (h *AppsHandler) CreateAppPreview(c *gin.Context) { ctx := c.Request.Context() // Get context idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { models.RespondWithError(c, models.NewErrValidation("Invalid app ID format", nil, err)) return } app, err := h.store.GetAppByID(ctx, int(id)) // Pass context if err != nil { models.RespondWithError(c, err) return } if app == nil { models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil)) return } h.entry.WithField("app_id", app.ID).Info("Creating manual preview for app") go h.createAppPreviewAsync(context.Background(), app.ID) // Use background context for async operation c.JSON(http.StatusOK, gin.H{"message": "Preview creation started"}) } // GetAppPreview returns the preview for a specific app func (h *AppsHandler) GetAppPreview(c *gin.Context) { ctx := c.Request.Context() // Get context idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { models.RespondWithError(c, models.NewErrValidation("Invalid app ID format", nil, err)) return } app, err := h.store.GetAppByID(ctx, int(id)) // Pass context if err != nil { models.RespondWithError(c, err) return } if app == nil { models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil)) return } preview, err := h.store.GetPreviewByAppID(ctx, app.ID) // Pass context if err != nil { models.RespondWithError(c, err) return } if preview == nil { models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("Preview for app ID %d not found", id), nil)) return } c.JSON(http.StatusOK, preview) } // DeleteAppPreview deletes the preview for a specific app func (h *AppsHandler) DeleteAppPreview(c *gin.Context) { ctx := c.Request.Context() // Get context idStr := c.Param("id") id, err := strconv.ParseInt(idStr, 10, 64) if err != nil { models.RespondWithError(c, models.NewErrValidation("Invalid app ID format", nil, err)) return } app, err := h.store.GetAppByID(ctx, int(id)) // Pass context if err != nil { models.RespondWithError(c, err) return } if app == nil { models.RespondWithError(c, models.NewErrNotFound(fmt.Sprintf("App with ID %d not found", id), nil)) return } if err := h.previewService.DeletePreview(ctx, app.ID); err != nil { // Pass context models.RespondWithError(c, err) return } c.JSON(http.StatusOK, gin.H{"message": "Preview deleted successfully"}) }