package api import ( "containr/internal/database" "containr/internal/database/sqlcdb" "containr/internal/deployment" "context" "database/sql" "errors" "log" "net/http" "strings" "time" "github.com/gin-gonic/gin" "github.com/google/uuid" ) type Project struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description"` OwnerID string `json:"owner_id"` IsApproved bool `json:"is_approved"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } type ProjectStats struct { ServiceCount int `json:"service_count"` DeploymentCount int `json:"deployment_count"` RunningServices int `json:"running_services"` LastDeployment *string `json:"last_deployment"` } type ProjectWithStats struct { Project Stats ProjectStats `json:"stats"` } type CreateProjectRequest struct { Name string `json:"name" binding:"required,min=2"` Description string `json:"description"` } type UpdateProjectRequest struct { Name *string `json:"name,omitempty"` Description *string `json:"description,omitempty"` } func handleGetProjects(c *gin.Context) { userID := optionalUserUUID(c) isAdmin := contextIsAdmin(c) db := c.MustGet("db").(*database.DB) queries := sqlcdb.New(db.DB) offset, limit := pageWindow(c, 10, 100) search := strings.TrimSpace(c.DefaultQuery("search", "")) searchParam := sql.NullString{} if search != "" { searchParam = sql.NullString{String: search, Valid: true} } rows, err := queries.ListProjectsWithStatsByUser(c.Request.Context(), sqlcdb.ListProjectsWithStatsByUserParams{ UserID: userID, IsAdmin: isAdmin, Search: searchParam, LimitCount: int32(limit), OffsetCount: int32(offset), }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database query error"}) return } projects := make([]ProjectWithStats, 0, len(rows)) for _, row := range rows { projects = append(projects, mapProjectWithStatsRow(row)) } total, err := queries.CountProjectsByUser(c.Request.Context(), sqlcdb.CountProjectsByUserParams{ UserID: userID, IsAdmin: isAdmin, Search: searchParam, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database count error"}) return } c.JSON(http.StatusOK, gin.H{ "projects": projects, "pagination": paginationMeta(offset, limit, int(total)), }) } func handleCreateProject(c *gin.Context) { userID, ok := requireAuthenticatedUserUUID(c) if !ok { return } db := c.MustGet("db").(*database.DB) queries := sqlcdb.New(db.DB) ctx := c.Request.Context() var req CreateProjectRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } req.Name = strings.TrimSpace(req.Name) if len(req.Name) < 2 { c.JSON(http.StatusBadRequest, gin.H{"error": "name must be at least 2 characters"}) return } tx, err := db.BeginTx(ctx, nil) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"}) return } defer tx.Rollback() txQueries := queries.WithTx(tx) projectRow, err := txQueries.CreateProject(ctx, sqlcdb.CreateProjectParams{ Name: req.Name, Description: nullableText(strings.TrimSpace(req.Description)), OwnerID: userID, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"}) return } environments := []string{"production", "preview", "development"} for _, env := range environments { err = txQueries.InsertProjectEnvironment(ctx, sqlcdb.InsertProjectEnvironmentParams{ Name: env, ProjectID: projectRow.ID, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create environments"}) return } } if err := tx.Commit(); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project"}) return } LogAuditWithRequest(c, "project", projectRow.ID.String(), "project.create", map[string]interface{}{"name": req.Name}) c.JSON(http.StatusCreated, mapSQLCProject(projectRow)) } func handleGetProject(c *gin.Context) { userID := optionalUserUUID(c) db := c.MustGet("db").(*database.DB) queries := sqlcdb.New(db.DB) projectID, ok := parseProjectIDParam(c) if !ok { return } projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{ ProjectID: projectID, UserID: userID, IsAdmin: contextIsAdmin(c), }) if errors.Is(err, sql.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } c.JSON(http.StatusOK, mapSQLCProject(projectRow)) } func handleUpdateProject(c *gin.Context) { userID, ok := requireAuthenticatedUserUUID(c) if !ok { return } db := c.MustGet("db").(*database.DB) queries := sqlcdb.New(db.DB) projectID, ok := parseProjectIDParam(c) if !ok { return } role, err := queries.GetProjectRoleForUser(c.Request.Context(), sqlcdb.GetProjectRoleForUserParams{ ProjectID: projectID, UserID: userID, IsAdmin: contextIsAdmin(c), }) if errors.Is(err, sql.ErrNoRows) || role == "" || role == "viewer" { c.JSON(http.StatusForbidden, gin.H{"error": "Insufficient permissions"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } var req UpdateProjectRequest if err := c.ShouldBindJSON(&req); err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } if req.Name == nil && req.Description == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "No fields to update"}) return } nameParam := sql.NullString{} if req.Name != nil { trimmedName := strings.TrimSpace(*req.Name) if len(trimmedName) < 2 { c.JSON(http.StatusBadRequest, gin.H{"error": "name must be at least 2 characters"}) return } nameParam = sql.NullString{String: trimmedName, Valid: true} } descriptionParam := sql.NullString{} if req.Description != nil { descriptionParam = sql.NullString{String: strings.TrimSpace(*req.Description), Valid: true} } updatedRows, err := queries.UpdateProjectByID(c.Request.Context(), sqlcdb.UpdateProjectByIDParams{ Name: nameParam, Description: descriptionParam, ProjectID: projectID, }) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project"}) return } if updatedRows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } projectRow, err := queries.GetProjectByIDForUser(c.Request.Context(), sqlcdb.GetProjectByIDForUserParams{ ProjectID: projectID, UserID: userID, IsAdmin: contextIsAdmin(c), }) if errors.Is(err, sql.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } c.JSON(http.StatusOK, mapSQLCProject(projectRow)) } func handleDeleteProject(c *gin.Context) { userID, ok := requireAuthenticatedUserUUID(c) if !ok { return } db := c.MustGet("db").(*database.DB) queries := sqlcdb.New(db.DB) projectID, ok := parseProjectIDParam(c) if !ok { return } ownerID, err := queries.GetProjectOwnerByID(c.Request.Context(), projectID) if errors.Is(err, sql.ErrNoRows) { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Database error"}) return } if ownerID != userID && !contextIsAdmin(c) { c.JSON(http.StatusForbidden, gin.H{"error": "Only project owners can delete projects"}) return } // Managed databases provisioned into this project (graph templates, // compose imports) carry project_id and must be torn down first — their // containers are not labelled containr.project and hold references to the // project network. if dbHandlerValue, exists := c.Get("database_handler"); exists && dbHandlerValue != nil { if dbHandler, ok := dbHandlerValue.(*DatabaseHandler); ok { ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) projectDBs, listErr := dbHandler.queries.ListDatabaseServicesByProject(ctx, uuid.NullUUID{UUID: projectID, Valid: true}) if listErr == nil { for _, projectDB := range projectDBs { if projectDB.Provider != "external" { if err := dbHandler.deleteManagedDatabaseRuntime(projectDB.ID); err != nil { log.Printf("delete project %s: database %s runtime removal failed: %v", projectID, projectDB.ID, err) } } _ = dbHandler.queries.DeleteDatabaseServiceByID(ctx, projectDB.ID) } } cancel() } } // Remove live containers and the project network before dropping rows. // Best-effort: Docker may be unavailable; DB cleanup proceeds regardless. if engineValue, exists := c.Get("deployment_engine"); exists && engineValue != nil { engine := engineValue.(*deployment.DeploymentEngine) ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) _ = engine.RemoveProjectContainers(ctx, projectID.String()) _ = engine.RemoveProjectNetwork(ctx, projectID.String()) cancel() } deletedRows, err := queries.DeleteProjectByID(c.Request.Context(), projectID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete project"}) return } if deletedRows == 0 { c.JSON(http.StatusNotFound, gin.H{"error": "Project not found"}) return } LogAuditWithRequest(c, "project", projectID.String(), "project.delete", nil) c.JSON(http.StatusOK, gin.H{"message": "Project deleted successfully"}) } func requireAuthenticatedUserUUID(c *gin.Context) (uuid.UUID, bool) { userID, ok := requireAuthenticatedUserID(c) if !ok { return uuid.Nil, false } parsedUserID, err := uuid.Parse(userID) if err != nil { c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"}) return uuid.Nil, false } return parsedUserID, true } func parseProjectIDParam(c *gin.Context) (uuid.UUID, bool) { projectID, err := uuid.Parse(c.Param("id")) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid project ID"}) return uuid.Nil, false } return projectID, true } func mapProjectWithStatsRow(row sqlcdb.ListProjectsWithStatsByUserRow) ProjectWithStats { result := ProjectWithStats{ Project: mapProjectFields(row.ID, row.Name, row.Description, row.OwnerID, row.IsApproved, row.CreatedAt, row.UpdatedAt), Stats: ProjectStats{ ServiceCount: int(row.ServiceCount), DeploymentCount: int(row.DeploymentCount), RunningServices: int(row.RunningServices), LastDeployment: nullableTimestampStringPtr(row.LastDeployment), }, } return result } func mapSQLCProject(project sqlcdb.Project) Project { return mapProjectFields(project.ID, project.Name, project.Description, project.OwnerID, project.IsApproved, project.CreatedAt, project.UpdatedAt) } func mapProjectFields(id uuid.UUID, name string, description sql.NullString, ownerID uuid.UUID, isApproved bool, createdAt, updatedAt sql.NullTime) Project { return Project{ ID: id.String(), Name: name, Description: nullableStringValue(description), OwnerID: ownerID.String(), IsApproved: isApproved, CreatedAt: nullableTimestampString(createdAt), UpdatedAt: nullableTimestampString(updatedAt), } } func nullableText(value string) sql.NullString { if value == "" { return sql.NullString{} } return sql.NullString{String: value, Valid: true} } func nullableStringValue(value sql.NullString) string { if value.Valid { return value.String } return "" } func nullableTimestampString(value sql.NullTime) string { if value.Valid { return value.Time.Format(time.RFC3339) } return "" } func nullableTimestampStringPtr(value sql.NullTime) *string { if value.Valid { formatted := value.Time.Format(time.RFC3339) return &formatted } return nil }