diff --git a/src/app/project/model.go b/src/app/project/model.go new file mode 100644 index 0000000000000000000000000000000000000000..858689c14a6849b76340919bea2bed9176832551 --- /dev/null +++ b/src/app/project/model.go @@ -0,0 +1,168 @@ +package project + +import ( + "time" + + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// Project represents the main project entity stored in MongoDB +// This struct defines the complete data structure for a project including +// metadata fields for tracking creation and modification times +type Project struct { + // ID is the unique MongoDB ObjectID that serves as the primary key + // The omitempty tag excludes this field if it's empty during insertion + ID primitive.ObjectID bson:"_id,omitempty" json:"id" + + // ProjectID is a human-readable unique identifier (e.g., "PRJ-2025-001") + // This allows users to reference projects with meaningful IDs + ProjectID string bson:"project_id" json:"projectId" + + // Name is the display name of the project (e.g., "HARMONY Mobile") + // This is what users see in the interface as the project title + Name string bson:"name" json:"name" + + // Description provides detailed information about the project's purpose + // This field is optional and can be empty for simple projects + Description string bson:"description" json:"description" + + // StartDate defines when the project begins + // Used for project timeline calculations and reporting + StartDate time.Time bson:"start_date" json:"startDate" + + // EndDate defines when the project is scheduled to complete + // Must be after StartDate as enforced by business logic + EndDate time.Time bson:"end_date" json:"endDate" + + // CreatedBy stores the UUID of the user who created this project + // This establishes ownership and is used for access control + CreatedBy uuid.UUID bson:"created_by" json:"createdBy" + + // CreatedAt records when this project was first created + // Automatically set during project creation for audit trails + CreatedAt time.Time bson:"created_at" json:"createdAt" + + // UpdatedAt tracks the last modification time + // Uses pointer to allow nil values when no updates have occurred + // The omitempty tag excludes this field if it's nil + UpdatedAt *time.Time bson:"updated_at,omitempty" json:"updatedAt,omitempty" +} + +// ProjectToCreate represents the data structure used when creating new projects +// This struct contains only the fields that users can specify during creation +// System-generated fields like ID and timestamps are handled separately +type ProjectToCreate struct { + // ProjectID must be provided by the user and must be unique + // Validation ensures this field is not empty + ProjectID string json:"projectId" hvalidate:"required" + + // Name is required and serves as the display title + // Validation ensures this field is not empty + Name string json:"name" hvalidate:"required" + + // Description is optional and can be left empty + // No validation constraints are applied to this field + Description string json:"description" + + // StartDate is required and must be a valid date + // Validation ensures this field is provided + StartDate time.Time json:"startDate" hvalidate:"required" + + // EndDate is required and must be after StartDate + // Basic validation ensures this field is provided + // Business logic validation ensures it's after StartDate + EndDate time.Time json:"endDate" hvalidate:"required" + + // CreatedBy is set by the system based on the current user + // The - tag excludes this from JSON serialization as it's not user input + CreatedBy uuid.UUID json:"-" +} + +// ProjectToUpdate represents the data structure used when updating existing projects +// This struct includes the project ID to identify which project to update +// Note: ProjectID is not included as it cannot be changed after creation +type ProjectToUpdate struct { + // ID identifies which project to update using the MongoDB ObjectID + // This field is populated from the URL parameter + ID primitive.ObjectID bson:"_id" json:"id" + + // Name can be updated and is required to be non-empty + // Users can change the display name of their projects + Name string json:"name" hvalidate:"required" + + // Description can be updated and can be empty + // Users can modify or clear the project description + Description string json:"description" + + // StartDate can be updated but must remain valid + // Users can adjust project timelines as needed + StartDate time.Time json:"startDate" hvalidate:"required" + + // EndDate can be updated but must remain after StartDate + // Business logic validation ensures timeline consistency + EndDate time.Time json:"endDate" hvalidate:"required" +} + +// ToUpdate converts a Project instance to a ProjectToUpdate structure +// This method is useful when pre-filling edit forms with existing project data +// It extracts only the fields that can be modified during updates +func (p *Project) ToUpdate() *ProjectToUpdate { + return &ProjectToUpdate{ + ID: p.ID, // Preserve the project identifier + Name: p.Name, // Current name as default + Description: p.Description, // Current description as default + StartDate: p.StartDate, // Current start date as default + EndDate: p.EndDate, // Current end date as default + } +} + +// ProjectWithRequirements represents a project along with its associated requirements +// This structure is used for project detail views where both project information +// and requirement summaries need to be displayed together +type ProjectWithRequirements struct { + // Embed the base Project struct to inherit all project fields + // This allows access to all project properties directly + *Project + + // Requirements contains summary information for all requirements in this project + // This is optimized for display purposes rather than full requirement details + Requirements []*RequirementSummary json:"requirements" + + // RequirementCount provides a quick count of total requirements + // This avoids the need to calculate len(Requirements) in templates + RequirementCount int json:"requirementCount" +} + +// RequirementSummary provides essential requirement information for project views +// This structure contains only the fields needed for requirement lists and summaries +// Full requirement details would be loaded separately when viewing individual requirements +type RequirementSummary struct { + // ID is the unique MongoDB ObjectID for this requirement + // Used for linking to detailed requirement views + ID primitive.ObjectID bson:"_id" json:"id" + + // RequirementID is a human-readable identifier for this requirement + // Generated automatically if not provided (e.g., "REQ-abc123") + RequirementID string bson:"requirement_id" json:"requirementId" + + // Condition describes the circumstances under which this requirement applies + // This field comes from the requirement parsing and validation process + Condition string bson:"condition" json:"condition" + + // System identifies which system or component this requirement affects + // This helps organize requirements by their target systems + System string bson:"system" json:"system" + + // Requirement contains the actual requirement text + // This is the main content that describes what must be implemented + Requirement string bson:"requirement" json:"requirement" + + // Status tracks the current state of this requirement + // Possible values include "Entwurf", "In Prüfung", "Genehmigt", "Abgelehnt" + Status string bson:"status" json:"status" + + // CreatedAt records when this requirement was first created + // Used for sorting and audit purposes + CreatedAt time.Time bson:"created_at" json:"createdAt" +} \ No newline at end of file diff --git a/src/app/project/mongodb.go b/src/app/project/mongodb.go new file mode 100644 index 0000000000000000000000000000000000000000..33bd24ab8b3b06df8fd9788b87f9cfa8e3af69e3 --- /dev/null +++ b/src/app/project/mongodb.go @@ -0,0 +1,97 @@ +package project + +import ( + "context" + "fmt" + "log" + "time" + + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// MongoManager handles MongoDB connection management and database operations +// This struct encapsulates the MongoDB client and database references +// It provides a centralized way to manage database connections throughout the application +type MongoManager struct { + client *mongo.Client // The MongoDB client for connection management + db *mongo.Database // The specific database instance for this application +} + +// NewMongoManager creates and initializes a new MongoDB connection manager +// This function establishes a connection to MongoDB with optimized settings +// and returns a manager instance that can be used throughout the application +func NewMongoManager(uri, dbName string) (*MongoManager, error) { + log.Printf("INFO: Connecting to MongoDB at %s, database: %s", uri, dbName) + + // Configure MongoDB client options for optimal performance + clientOptions := options.Client().ApplyURI(uri) + + // Set connection pool parameters for handling concurrent requests efficiently + // MaxPoolSize: Maximum number of connections in the pool (100 connections) + // This allows the application to handle many concurrent database operations + clientOptions.SetMaxPoolSize(100) + + // MinPoolSize: Minimum number of connections to maintain (10 connections) + // This ensures quick response times by keeping connections ready + clientOptions.SetMinPoolSize(10) + + // MaxConnIdleTime: How long idle connections stay open (30 seconds) + // This balances resource usage with connection availability + clientOptions.SetMaxConnIdleTime(30 * time.Second) + + log.Printf("DEBUG: MongoDB connection pool configured - max: 100, min: 10, idle timeout: 30s") + + // Attempt to create a connection to MongoDB using the configured options + client, err := mongo.Connect(context.TODO(), clientOptions) + if err != nil { + log.Printf("ERROR: Failed to connect to MongoDB: %v", err) + return nil, fmt.Errorf("failed to connect to MongoDB: %w", err) + } + + log.Println("DEBUG: MongoDB client created, testing connection...") + + // Test the connection by pinging the MongoDB server + // This ensures that the connection is actually working before proceeding + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() // Ensure the context is cleaned up after the ping + + if err := client.Ping(ctx, nil); err != nil { + log.Printf("ERROR: MongoDB ping failed: %v", err) + return nil, fmt.Errorf("failed to ping MongoDB: %w", err) + } + + log.Printf("INFO: Successfully connected to MongoDB database '%s'", dbName) + + // Return a configured MongoManager instance + return &MongoManager{ + client: client, // Store the client for connection management + db: client.Database(dbName), // Store the database reference for operations + }, nil +} + +// GetDatabase returns the MongoDB database instance for performing operations +// This method provides access to the database for repositories and other components +// that need to interact with MongoDB collections +func (m *MongoManager) GetDatabase() *mongo.Database { + log.Printf("DEBUG: Returning database instance: %s", m.db.Name()) + return m.db +} + +// Close gracefully shuts down the MongoDB connection +// This method should be called when the application is shutting down +// to ensure all connections are properly closed and resources are released +func (m *MongoManager) Close() error { + log.Println("INFO: Closing MongoDB connection...") + + // Disconnect the MongoDB client using a background context + // This ensures that ongoing operations can complete before disconnection + err := m.client.Disconnect(context.Background()) + if err != nil { + log.Printf("ERROR: Failed to close MongoDB connection: %v", err) + return err + } + + log.Println("INFO: MongoDB connection closed successfully") + return nil +} diff --git a/src/app/project/repository.go b/src/app/project/repository.go new file mode 100644 index 0000000000000000000000000000000000000000..164ff09bcff81560690b25aac0b4fad14d1f55a8 --- /dev/null +++ b/src/app/project/repository.go @@ -0,0 +1,393 @@ +package project + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +// MongoDB collection names used throughout the application +// These constants ensure consistent collection naming and make changes easier +const ( + ProjectCollectionName = "projects" // Main projects collection + RequirementCollectionName = "requirements" // Requirements associated with projects +) + +// ProjectRepository defines the interface for project data access operations +// This interface abstracts the underlying database technology and allows for +// easy testing with mock implementations or switching database providers +type ProjectRepository interface { + // Create inserts a new project into the database + Create(ctx context.Context, project *ProjectToCreate) (*Project, error) + + // FindByID retrieves a project using its MongoDB ObjectID + FindByID(ctx context.Context, id primitive.ObjectID) (*Project, error) + + // FindByProjectID retrieves a project using its human-readable project ID + FindByProjectID(ctx context.Context, projectID string) (*Project, error) + + // FindByCreatedBy retrieves all projects created by a specific user + FindByCreatedBy(ctx context.Context, userID uuid.UUID) ([]*Project, error) + + // FindWithRequirements retrieves a project along with all its requirements + FindWithRequirements(ctx context.Context, id primitive.ObjectID) (*ProjectWithRequirements, error) + + // Update modifies an existing project with new data + Update(ctx context.Context, update *ProjectToUpdate) (*Project, error) + + // Delete removes a project and all associated requirements + Delete(ctx context.Context, id primitive.ObjectID) error + + // Search finds projects matching a text query for a specific user + Search(ctx context.Context, query string, userID uuid.UUID) ([]*Project, error) +} + +// MongoProjectRepository implements ProjectRepository using MongoDB +// This struct contains MongoDB-specific implementation details and connection handling +type MongoProjectRepository struct { + projectCollection *mongo.Collection // MongoDB collection for projects + requirementCollection *mongo.Collection // MongoDB collection for requirements + timeout time.Duration // Default timeout for database operations +} + +// NewMongoProjectRepository creates a new MongoDB-based repository instance +// This constructor initializes the repository with references to the required collections +func NewMongoProjectRepository(db *mongo.Database) ProjectRepository { + log.Printf("INFO: Creating MongoProjectRepository with database %s", db.Name()) + return &MongoProjectRepository{ + projectCollection: db.Collection(ProjectCollectionName), + requirementCollection: db.Collection(RequirementCollectionName), + timeout: 10 * time.Second, // Standard timeout for all operations + } +} + +// Create inserts a new project document into the MongoDB projects collection +// This method handles the conversion from ProjectToCreate to Project and sets system fields +func (r *MongoProjectRepository) Create(ctx context.Context, toCreate *ProjectToCreate) (*Project, error) { + // Set a timeout for this operation to prevent hanging connections + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + log.Printf("DEBUG: Creating project document for '%s'", toCreate.ProjectID) + + // Convert the creation request to a full project document + // Set system-generated fields like ID and creation timestamp + project := &Project{ + ID: primitive.NewObjectID(), // Generate new MongoDB ObjectID + ProjectID: toCreate.ProjectID, // User-provided project identifier + Name: toCreate.Name, // Project display name + Description: toCreate.Description, // Optional project description + StartDate: toCreate.StartDate, // Project start date + EndDate: toCreate.EndDate, // Project end date + CreatedBy: toCreate.CreatedBy, // User who created this project + CreatedAt: time.Now(), // Current timestamp + } + + log.Printf("DEBUG: Inserting project into MongoDB collection %s", ProjectCollectionName) + + // Insert the project document into MongoDB + result, err := r.projectCollection.InsertOne(ctx, project) + if err != nil { + log.Printf("ERROR: MongoDB insert failed for project '%s': %v", toCreate.ProjectID, err) + return nil, fmt.Errorf("failed to create project: %w", err) + } + + // Update the project with the actual ObjectID assigned by MongoDB + if oid, ok := result.InsertedID.(primitive.ObjectID); ok { + project.ID = oid + log.Printf("INFO: Project '%s' inserted with MongoDB ID %s", project.ProjectID, oid.Hex()) + } + + return project, nil +} + +// FindByID retrieves a project using its MongoDB ObjectID +// This is the primary method for finding projects when you have the database ID +func (r *MongoProjectRepository) FindByID(ctx context.Context, id primitive.ObjectID) (*Project, error) { + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + log.Printf("DEBUG: Finding project by MongoDB ID %s", id.Hex()) + + var project Project + // Query MongoDB using the _id field (primary key) + err := r.projectCollection.FindOne(ctx, bson.M{"_id": id}).Decode(&project) + if err != nil { + if err == mongo.ErrNoDocuments { + log.Printf("WARN: Project with ID %s not found", id.Hex()) + return nil, fmt.Errorf("project not found") + } + log.Printf("ERROR: MongoDB query failed for project ID %s: %v", id.Hex(), err) + return nil, fmt.Errorf("failed to find project: %w", err) + } + + log.Printf("INFO: Found project '%s' (ProjectID: %s)", project.Name, project.ProjectID) + return &project, nil +} + +// FindByProjectID retrieves a project using its human-readable project identifier +// This method is used when checking for duplicate project IDs during creation +func (r *MongoProjectRepository) FindByProjectID(ctx context.Context, projectID string) (*Project, error) { + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + log.Printf("DEBUG: Finding project by ProjectID '%s'", projectID) + + var project Project + // Query using the project_id field (user-defined identifier) + err := r.projectCollection.FindOne(ctx, bson.M{"project_id": projectID}).Decode(&project) + if err != nil { + if err == mongo.ErrNoDocuments { + log.Printf("DEBUG: Project with ProjectID '%s' not found (this may be expected)", projectID) + return nil, fmt.Errorf("project not found") + } + log.Printf("ERROR: MongoDB query failed for ProjectID '%s': %v", projectID, err) + return nil, fmt.Errorf("failed to find project: %w", err) + } + + log.Printf("INFO: Found project '%s' by ProjectID '%s'", project.Name, projectID) + return &project, nil +} + +// FindByCreatedBy retrieves all projects created by a specific user +// Results are sorted by creation date (newest first) for better user experience +func (r *MongoProjectRepository) FindByCreatedBy(ctx context.Context, userID uuid.UUID) ([]*Project, error) { + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + log.Printf("DEBUG: Finding projects for user %s", userID) + + // Query for all projects where created_by matches the user ID + // Sort by created_at in descending order (newest first) + cursor, err := r.projectCollection.Find( + ctx, + bson.M{"created_by": userID}, // Filter condition + options.Find().SetSort(bson.D{{"created_at", -1}}), // Sort options + ) + if err != nil { + log.Printf("ERROR: MongoDB query failed for user %s: %v", userID, err) + return nil, fmt.Errorf("failed to find projects: %w", err) + } + defer cursor.Close(ctx) // Ensure cursor is closed to free resources + + // Iterate through the cursor and decode each document + var projects []*Project + for cursor.Next(ctx) { + var project Project + if err := cursor.Decode(&project); err != nil { + log.Printf("ERROR: Failed to decode project document: %v", err) + return nil, fmt.Errorf("failed to decode project: %w", err) + } + projects = append(projects, &project) + } + + log.Printf("INFO: Found %d projects for user %s", len(projects), userID) + return projects, cursor.Err() // Return any cursor iteration errors +} + +// FindWithRequirements retrieves a project along with all its associated requirements +// This method is optimized for project detail pages where both sets of data are needed +func (r *MongoProjectRepository) FindWithRequirements(ctx context.Context, id primitive.ObjectID) (*ProjectWithRequirements, error) { + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + log.Printf("DEBUG: Finding project with requirements for ID %s", id.Hex()) + + // First, load the base project information + project, err := r.FindByID(ctx, id) + if err != nil { + log.Printf("ERROR: Failed to find base project for ID %s: %v", id.Hex(), err) + return nil, err + } + + log.Printf("DEBUG: Loading requirements for project '%s' (ProjectID: %s)", project.Name, project.ProjectID) + + // Load all requirements associated with this project + // Requirements are linked by the project_id field (not MongoDB ObjectID) + cursor, err := r.requirementCollection.Find( + ctx, + bson.M{"project_id": project.ProjectID}, // Link by project_id + options.Find().SetSort(bson.D{{"created_at", -1}}), // Newest first + ) + if err != nil { + log.Printf("ERROR: Failed to query requirements for project '%s': %v", project.ProjectID, err) + return nil, fmt.Errorf("failed to find requirements: %w", err) + } + defer cursor.Close(ctx) + + // Process each requirement document + var requirements []*RequirementSummary + for cursor.Next(ctx) { + var req RequirementSummary + if err := cursor.Decode(&req); err != nil { + log.Printf("WARN: Skipping invalid requirement document: %v", err) + continue // Skip documents that can't be decoded rather than failing entirely + } + + // Extract additional fields from nested parsing_result if available + // This handles requirements that have been processed by the EIFFEL system + var doc bson.M + cursor.Decode(&doc) + if parsingResult, ok := doc["parsing_result"].(bson.M); ok { + if requirement, ok := parsingResult["requirement"].(string); ok { + req.Requirement = requirement + } + } + + // Set default values for missing fields to ensure consistency + if req.Status == "" { + req.Status = "Entwurf" // Default status for new requirements + } + if req.RequirementID == "" { + // Generate a default requirement ID from the MongoDB ObjectID + req.RequirementID = fmt.Sprintf("REQ-%s", req.ID.Hex()[:6]) + } + + requirements = append(requirements, &req) + } + + log.Printf("INFO: Loaded project '%s' with %d requirements", project.Name, len(requirements)) + + // Return the combined structure with project and requirements + return &ProjectWithRequirements{ + Project: project, + Requirements: requirements, + RequirementCount: len(requirements), + }, nil +} + +// Update modifies an existing project with new data and sets the updated timestamp +// This method only updates the fields that can be modified, preserving system fields +func (r *MongoProjectRepository) Update(ctx context.Context, update *ProjectToUpdate) (*Project, error) { + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + log.Printf("DEBUG: Updating project %s with name '%s'", update.ID.Hex(), update.Name) + + // Prepare the update document with new values and current timestamp + now := time.Now() + updateDoc := bson.M{ + "$set": bson.M{ + "name": update.Name, // Updated project name + "description": update.Description, // Updated description + "start_date": update.StartDate, // Updated start date + "end_date": update.EndDate, // Updated end date + "updated_at": now, // Set current time as update timestamp + }, + } + + // Execute the update operation + result, err := r.projectCollection.UpdateOne( + ctx, + bson.M{"_id": update.ID}, // Find the project by ObjectID + updateDoc, // Apply the updates + ) + if err != nil { + log.Printf("ERROR: MongoDB update failed for project %s: %v", update.ID.Hex(), err) + return nil, fmt.Errorf("failed to update project: %w", err) + } + + log.Printf("INFO: Project %s updated - matched: %d, modified: %d", + update.ID.Hex(), result.MatchedCount, result.ModifiedCount) + + // Return the updated project by fetching it again + // This ensures we return the most current data including the updated timestamp + return r.FindByID(ctx, update.ID) +} + +// Delete removes a project and all its associated requirements from the database +// This method ensures referential integrity by cleaning up related data +func (r *MongoProjectRepository) Delete(ctx context.Context, id primitive.ObjectID) error { + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + log.Printf("DEBUG: Starting deletion process for project %s", id.Hex()) + + // First, find the project to get its ProjectID for requirement cleanup + project, err := r.FindByID(ctx, id) + if err != nil { + log.Printf("ERROR: Cannot find project %s for deletion: %v", id.Hex(), err) + return err + } + + log.Printf("INFO: Deleting project '%s' and all its requirements", project.Name) + + // Delete all requirements associated with this project + // Requirements are linked by project_id (string) not MongoDB ObjectID + reqDeleteResult, err := r.requirementCollection.DeleteMany(ctx, bson.M{"project_id": project.ProjectID}) + if err != nil { + log.Printf("ERROR: Failed to delete requirements for project '%s': %v", project.ProjectID, err) + return fmt.Errorf("failed to delete project requirements: %w", err) + } + + log.Printf("INFO: Deleted %d requirements for project '%s'", reqDeleteResult.DeletedCount, project.ProjectID) + + // Delete the project itself + projDeleteResult, err := r.projectCollection.DeleteOne(ctx, bson.M{"_id": id}) + if err != nil { + log.Printf("ERROR: Failed to delete project %s: %v", id.Hex(), err) + return fmt.Errorf("failed to delete project: %w", err) + } + + // Verify that the project was actually deleted + if projDeleteResult.DeletedCount == 0 { + log.Printf("WARN: No project was deleted for ID %s", id.Hex()) + } else { + log.Printf("INFO: Project '%s' deleted successfully", project.Name) + } + + return nil +} + +// Search finds projects matching a text query across multiple fields +// This method provides flexible search functionality for the project list interface +func (r *MongoProjectRepository) Search(ctx context.Context, query string, userID uuid.UUID) ([]*Project, error) { + ctx, cancel := context.WithTimeout(ctx, r.timeout) + defer cancel() + + log.Printf("DEBUG: Searching projects for user %s with query '%s'", userID, query) + + // Build a MongoDB query that searches across multiple fields + // Uses case-insensitive regular expressions for flexible matching + filter := bson.M{ + "created_by": userID, // Only search projects owned by this user + "$or": []bson.M{ // Search across multiple fields + {"name": bson.M{"$regex": query, "$options": "i"}}, // Project name + {"description": bson.M{"$regex": query, "$options": "i"}}, // Project description + {"project_id": bson.M{"$regex": query, "$options": "i"}}, // Project ID + }, + } + + // Execute the search query with sorting + cursor, err := r.projectCollection.Find( + ctx, + filter, + options.Find().SetSort(bson.D{{"created_at", -1}}), // Newest first + ) + if err != nil { + log.Printf("ERROR: MongoDB search query failed for '%s': %v", query, err) + return nil, fmt.Errorf("failed to search projects: %w", err) + } + defer cursor.Close(ctx) + + // Decode the search results + var projects []*Project + for cursor.Next(ctx) { + var project Project + if err := cursor.Decode(&project); err != nil { + log.Printf("ERROR: Failed to decode project in search results: %v", err) + return nil, fmt.Errorf("failed to decode project: %w", err) + } + projects = append(projects, &project) + } + + log.Printf("INFO: Search completed - found %d projects for query '%s'", len(projects), query) + return projects, cursor.Err() +} diff --git a/src/app/project/service.go b/src/app/project/service.go new file mode 100644 index 0000000000000000000000000000000000000000..03691430ffd83bab2f88810799d2e545ad34cac4 --- /dev/null +++ b/src/app/project/service.go @@ -0,0 +1,176 @@ +package project + +import ( + "context" + "fmt" + "log" + + "github.com/google/uuid" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// ProjectService contains the business logic for project management operations +// This service layer sits between the web controllers and the data repository +// It enforces business rules, handles validation, and coordinates data operations +type ProjectService struct { + repo ProjectRepository // Repository interface for data access operations +} + +// NewProjectService creates and initializes a new ProjectService instance +// The service requires a repository implementation to handle data persistence +func NewProjectService(repo ProjectRepository) *ProjectService { + log.Println("INFO: ProjectService initialized") + return &ProjectService{repo: repo} +} + +// CreateProject handles the creation of new projects with business logic validation +// This method ensures project uniqueness, validates business rules, and creates the project +func (s *ProjectService) CreateProject(ctx context.Context, toCreate *ProjectToCreate) (*Project, error) { + log.Printf("INFO: Creating project with ID '%s' and name '%s'", toCreate.ProjectID, toCreate.Name) + + // Business Rule 1: Project IDs must be unique across the entire system + // Check if a project with this ID already exists before creating a new one + log.Printf("DEBUG: Checking if project ID '%s' already exists", toCreate.ProjectID) + existing, err := s.repo.FindByProjectID(ctx, toCreate.ProjectID) + if err == nil && existing != nil { + // If we found an existing project, the creation should fail + log.Printf("WARN: Project creation failed - ID '%s' already exists", toCreate.ProjectID) + return nil, fmt.Errorf("project with ID '%s' already exists", toCreate.ProjectID) + } + + // Business Rule 2: Project end date must be after the start date + // This prevents creating projects with invalid date ranges + if toCreate.EndDate.Before(toCreate.StartDate) { + log.Printf("WARN: Project creation failed - end date (%s) before start date (%s)", + toCreate.EndDate.Format("2006-01-02"), toCreate.StartDate.Format("2006-01-02")) + return nil, fmt.Errorf("end date must be after start date") + } + + log.Printf("DEBUG: Project validation passed, creating in repository") + + // All business rules passed, delegate to repository for actual creation + project, err := s.repo.Create(ctx, toCreate) + if err != nil { + log.Printf("ERROR: Failed to create project '%s' in repository: %v", toCreate.ProjectID, err) + return nil, err + } + + log.Printf("INFO: Project '%s' created successfully with MongoDB ID %s", + project.ProjectID, project.ID.Hex()) + return project, nil +} + +// GetProjectsByUser retrieves all projects belonging to a specific user +// This method provides a simple interface for fetching user-specific projects +func (s *ProjectService) GetProjectsByUser(ctx context.Context, userID uuid.UUID) ([]*Project, error) { + log.Printf("INFO: Getting projects for user %s", userID) + + // Delegate to repository for data retrieval + projects, err := s.repo.FindByCreatedBy(ctx, userID) + if err != nil { + log.Printf("ERROR: Failed to get projects for user %s: %v", userID, err) + return nil, err + } + + log.Printf("INFO: Found %d projects for user %s", len(projects), userID) + + // Debug logging: Show project names for troubleshooting + if len(projects) > 0 { + log.Printf("DEBUG: Projects found: %v", func() []string { + names := make([]string, len(projects)) + for i, p := range projects { + names[i] = p.Name + } + return names + }()) + } + + return projects, nil +} + +// GetProjectWithRequirements retrieves a project along with all its associated requirements +// This method is optimized for displaying project detail pages where both +// project information and requirements are needed simultaneously +func (s *ProjectService) GetProjectWithRequirements(ctx context.Context, id primitive.ObjectID) (*ProjectWithRequirements, error) { + log.Printf("INFO: Getting project with requirements for ID %s", id.Hex()) + + // Use the repository method that efficiently loads project and requirements together + // This is more efficient than making separate calls for project and requirements + projectWithReqs, err := s.repo.FindWithRequirements(ctx, id) + if err != nil { + log.Printf("ERROR: Failed to get project with requirements for ID %s: %v", id.Hex(), err) + return nil, err + } + + log.Printf("INFO: Loaded project '%s' with %d requirements", + projectWithReqs.Project.Name, projectWithReqs.RequirementCount) + + return projectWithReqs, nil +} + +// UpdateProject handles project updates with business rule validation +// This method ensures that updates maintain data integrity and business rules +func (s *ProjectService) UpdateProject(ctx context.Context, update *ProjectToUpdate) (*Project, error) { + log.Printf("INFO: Updating project %s with name '%s'", update.ID.Hex(), update.Name) + + // Business Rule: End date must still be after start date after the update + // This validation is applied to updates just like it is for creation + if update.EndDate.Before(update.StartDate) { + log.Printf("WARN: Project update failed - end date (%s) before start date (%s)", + update.EndDate.Format("2006-01-02"), update.StartDate.Format("2006-01-02")) + return nil, fmt.Errorf("end date must be after start date") + } + + log.Printf("DEBUG: Project update validation passed, updating in repository") + + // Delegate to repository for the actual update operation + project, err := s.repo.Update(ctx, update) + if err != nil { + log.Printf("ERROR: Failed to update project %s: %v", update.ID.Hex(), err) + return nil, err + } + + log.Printf("INFO: Project '%s' updated successfully", project.Name) + return project, nil +} + +// DeleteProject handles the complete removal of a project and all associated data +// This method ensures that all related requirements are also deleted to maintain data consistency +func (s *ProjectService) DeleteProject(ctx context.Context, id primitive.ObjectID) error { + log.Printf("INFO: Deleting project with ID %s", id.Hex()) + + // The repository handles the cascading deletion of requirements + // This ensures that no orphaned requirements remain in the database + err := s.repo.Delete(ctx, id) + if err != nil { + log.Printf("ERROR: Failed to delete project %s: %v", id.Hex(), err) + return err + } + + log.Printf("INFO: Project %s and all its requirements deleted successfully", id.Hex()) + return nil +} + +// SearchProjects provides flexible project search functionality +// This method handles both empty queries (return all projects) and text-based searches +func (s *ProjectService) SearchProjects(ctx context.Context, query string, userID uuid.UUID) ([]*Project, error) { + // Handle empty search queries by returning all projects for the user + // This provides a consistent interface regardless of whether a search term is provided + if query == "" { + log.Printf("INFO: Empty search query, returning all projects for user %s", userID) + return s.repo.FindByCreatedBy(ctx, userID) + } + + log.Printf("INFO: Searching projects for user %s with query '%s'", userID, query) + + // Delegate to repository for the actual search implementation + // The repository handles the specifics of text searching across project fields + projects, err := s.repo.Search(ctx, query, userID) + if err != nil { + log.Printf("ERROR: Search failed for query '%s' and user %s: %v", query, userID, err) + return nil, err + } + + log.Printf("INFO: Search completed - found %d projects for query '%s'", len(projects), query) + return projects, nil +} diff --git a/src/app/project/web/web.go b/src/app/project/web/web.go new file mode 100644 index 0000000000000000000000000000000000000000..198aff1e64ff14e07a472e95cdc3ba65d93d5a49 --- /dev/null +++ b/src/app/project/web/web.go @@ -0,0 +1,492 @@ +package web + +import ( + "errors" + "fmt" + "log" + "net/http" + "time" + + "github.com/org-harmony/harmony/src/app/user" + "github.com/org-harmony/harmony/src/core/hctx" + "github.com/org-harmony/harmony/src/core/validation" + "github.com/org-harmony/harmony/src/core/web" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +// ProjectFormData represents the data structure needed for rendering project forms +// This struct is used for both creating new projects and editing existing ones +type ProjectFormData struct { + Project any // Can hold either *project.ProjectToCreate or *project.ProjectToUpdate + IsEditForm bool // Flag to determine if this is an edit form (true) or create form (false) +} + +// ProjectListData contains the list of projects for template rendering +// This structure is passed to the project list template +type ProjectListData struct { + Projects []*project.Project // Array of all projects belonging to a user +} + +// ProjectDetailData holds all necessary data for displaying detailed project information +// including tab navigation and requirement management +type ProjectDetailData struct { + Project *project.ProjectWithRequirements // The project with all its requirements loaded + ActiveTab string // Currently active tab: "general" or "requirements" + RequirementStatuses []string // Available status options for requirements +} + +// RegisterController sets up all HTTP routes and handlers for project management +// This function is called during application startup to configure the web routing +func RegisterController(appCtx *hctx.AppCtx, webCtx *web.Ctx, mongoManager *project.MongoManager) { + log.Println("INFO: Registering project controllers...") + + // Initialize the data access layer (repository) and business logic layer (service) + // Repository handles MongoDB operations, Service handles business rules + projectRepo := project.NewMongoProjectRepository(mongoManager.GetDatabase()) + projectService := project.NewProjectService(projectRepo) + log.Println("INFO: Project repository and service initialized") + + // Create a router group that requires user authentication + // All routes defined here will require a logged-in user + router := webCtx.Router.With(user.LoggedInMiddleware(appCtx)) + + // Define all HTTP routes for project management: + + // GET /project/list - Display all projects for the current user + router.Get("/project/list", projectListController(appCtx, webCtx, projectService).ServeHTTP) + + // GET /project/new - Show form for creating a new project + router.Get("/project/new", projectNewController(appCtx, webCtx).ServeHTTP) + + // POST /project/new - Process new project creation form submission + router.Post("/project/new", projectCreateController(appCtx, webCtx, projectService).ServeHTTP) + + // GET /project/{id} - Display detailed view of a specific project + router.Get("/project/{id}", projectDetailController(appCtx, webCtx, projectService).ServeHTTP) + + // GET /project/{id}/edit - Show form for editing an existing project + router.Get("/project/{id}/edit", projectEditFormController(appCtx, webCtx, projectService).ServeHTTP) + + // PUT /project/{id} - Process project update form submission + router.Put("/project/{id}", projectUpdateController(appCtx, webCtx, projectService).ServeHTTP) + + // DELETE /project/{id} - Delete a project and all its requirements + router.Delete("/project/{id}", projectDeleteController(appCtx, webCtx, projectService).ServeHTTP) + + // POST /project/search - Handle project search functionality + router.Post("/project/search", projectSearchController(appCtx, webCtx, projectService).ServeHTTP) + + log.Println("INFO: Project routes registered successfully") +} + +// projectListController handles requests to display all projects belonging to the current user +// This controller fetches projects from the database and renders them in a list view +func projectListController(appCtx *hctx.AppCtx, webCtx *web.Ctx, service *project.ProjectService) http.Handler { + return web.NewController(appCtx, webCtx, func(io web.IO) error { + ctx := io.Context() + // Extract the currently logged-in user from the request context + // This user information was added by the authentication middleware + usr := user.MustCtxUser(ctx) + + log.Printf("INFO: Loading project list for user %s", usr.ID) + + // Fetch all projects created by this user from the database + // This calls through the service layer to maintain separation of concerns + projects, err := service.GetProjectsByUser(ctx, usr.ID) + if err != nil { + log.Printf("ERROR: Failed to get projects for user %s: %v", usr.ID, err) + return io.Error(web.ErrInternal, err) + } + + log.Printf("INFO: Found %d projects for user %s", len(projects), usr.ID) + + // Prepare data structure for template rendering + data := ProjectListData{Projects: projects} + log.Printf("DEBUG: Rendering project list with %d projects", len(data.Projects)) + + // Render the template with the project data + // The template system will wrap this data in BaseTemplateData automatically + err = io.Render( + data, + "project.list.page", // Template name to execute + "project/list-page.go.html", // Main page template file + "project/_list.go.html", // Partial template for the project list + ) + + if err != nil { + log.Printf("ERROR: Failed to render project list: %v", err) + return err + } + + log.Println("INFO: Project list rendered successfully") + return nil + }) +} + +// projectNewController displays an empty form for creating a new project +// This controller prepares default values and renders the project creation form +func projectNewController(appCtx *hctx.AppCtx, webCtx *web.Ctx) http.Handler { + return web.NewController(appCtx, webCtx, func(io web.IO) error { + log.Println("INFO: Showing new project form") + + // Create a new project structure with sensible default values + // Start date is today, end date is 6 months in the future + toCreate := &project.ProjectToCreate{ + StartDate: time.Now(), + EndDate: time.Now().AddDate(0, 6, 0), // Add 6 months to current date + } + + // Render the project form with empty/default values + return renderProjectForm(io, &ProjectFormData{ + Project: toCreate, + IsEditForm: false, // This is a creation form, not an edit form + }, nil, nil) + }) +} + +// projectCreateController processes the form submission for creating a new project +// This controller validates the input, creates the project, and handles success/error cases +func projectCreateController(appCtx *hctx.AppCtx, webCtx *web.Ctx, service *project.ProjectService) http.Handler { + return web.NewController(appCtx, webCtx, func(io web.IO) error { + ctx := io.Context() + // Get the current user to associate the new project with them + usr := user.MustCtxUser(ctx) + + log.Printf("INFO: Creating new project for user %s", usr.ID) + + // Initialize a new project creation object with the user ID + toCreate := &project.ProjectToCreate{CreatedBy: usr.ID} + + // Parse and validate the form data from the HTTP request + // This extracts form fields and validates them against defined rules + err, validationErrs := web.ReadForm(io.Request(), toCreate, appCtx.Validator) + if err != nil { + log.Printf("ERROR: Failed to read project form: %v", err) + return io.Error(web.ErrInternal, err) + } + + // If there are validation errors, redisplay the form with error messages + if validationErrs != nil { + log.Printf("WARN: Project creation validation failed: %d errors", len(validationErrs)) + return renderProjectForm(io, &ProjectFormData{ + Project: toCreate, + IsEditForm: false, + }, nil, validationErrs) + } + + log.Printf("INFO: Creating project with ID '%s' and name '%s'", toCreate.ProjectID, toCreate.Name) + + // Attempt to create the project using the business logic service + // The service will handle additional validation and database operations + newProject, err := service.CreateProject(ctx, toCreate) + if err != nil { + log.Printf("ERROR: Failed to create project '%s': %v", toCreate.ProjectID, err) + // If creation fails, redisplay the form with the error message + return renderProjectForm(io, &ProjectFormData{ + Project: toCreate, + IsEditForm: false, + }, nil, []error{validation.Error{Msg: err.Error()}}) + } + + log.Printf("INFO: Project '%s' created successfully with MongoDB ID %s", newProject.ProjectID, newProject.ID.Hex()) + + // On successful creation, redirect to the project detail page + return io.Redirect(fmt.Sprintf("/project/%s", newProject.ID.Hex()), http.StatusFound) + }) +} + +// projectDetailController displays detailed information about a specific project +// This includes general project info and requirements, organized in tabs +func projectDetailController(appCtx *hctx.AppCtx, webCtx *web.Ctx, service *project.ProjectService) http.Handler { + return web.NewController(appCtx, webCtx, func(io web.IO) error { + ctx := io.Context() + + // Extract the project ID from the URL parameters + id, err := parseObjectID(io.Request(), "id") + if err != nil { + log.Printf("ERROR: Invalid project ID in URL: %v", err) + return io.Error(web.ErrInternal, err) + } + + log.Printf("INFO: Loading project details for ID %s", id.Hex()) + + // Fetch the project along with all its requirements from the database + // This is more efficient than making separate calls for project and requirements + projectWithReqs, err := service.GetProjectWithRequirements(ctx, id) + if err != nil { + log.Printf("ERROR: Failed to get project with requirements for ID %s: %v", id.Hex(), err) + return io.Error(web.ErrInternal, err) + } + + // Determine which tab should be active (from URL query parameter) + // Default to "general" tab if no tab is specified + activeTab := io.Request().URL.Query().Get("tab") + if activeTab == "" { + activeTab = "general" + } + + log.Printf("INFO: Loaded project '%s' with %d requirements, showing tab '%s'", + projectWithReqs.Project.Name, projectWithReqs.RequirementCount, activeTab) + + // Prepare all data needed for the project detail template + data := ProjectDetailData{ + Project: projectWithReqs, + ActiveTab: activeTab, + RequirementStatuses: []string{"Entwurf", "In Prüfung", "Genehmigt", "Abgelehnt"}, // Available status options + } + + // Render the project detail page with multiple template files + err = io.Render( + data, + "project.detail.page", // Main template name + "templates/project/detail-page.go.html", // Page wrapper template + "templates/project/_detail.go.html", // Project detail content + "templates/project/_requirements-list.go.html", // Requirements list partial + ) + + if err != nil { + log.Printf("ERROR: Failed to render project detail page: %v", err) + return err + } + + log.Println("INFO: Project detail page rendered successfully") + return nil + }) +} + +// projectEditFormController displays a form pre-filled with existing project data for editing +// This controller loads the current project data and renders it in an editable form +func projectEditFormController(appCtx *hctx.AppCtx, webCtx *web.Ctx, service *project.ProjectService) http.Handler { + return web.NewController(appCtx, webCtx, func(io web.IO) error { + ctx := io.Context() + + // Parse the project ID from the URL + id, err := parseObjectID(io.Request(), "id") + if err != nil { + log.Printf("ERROR: Invalid project ID for edit: %v", err) + return io.InlineError(web.ErrInternal, err) + } + + log.Printf("INFO: Loading project edit form for ID %s", id.Hex()) + + // Get all projects for the current user to find the requested project + // This ensures the user can only edit their own projects + proj, err := service.GetProjectsByUser(ctx, user.MustCtxUser(ctx).ID) + if err != nil { + log.Printf("ERROR: Failed to get projects for edit: %v", err) + return io.InlineError(web.ErrInternal, err) + } + + // Search through the user's projects to find the one being edited + var targetProject *project.Project + for _, p := range proj { + if p.ID == id { + targetProject = p + break + } + } + + // If the project is not found, it either doesn't exist or doesn't belong to this user + if targetProject == nil { + log.Printf("ERROR: Project with ID %s not found for edit", id.Hex()) + return io.InlineError(web.ErrInternal, errors.New("project not found")) + } + + log.Printf("INFO: Showing edit form for project '%s'", targetProject.Name) + + // Convert the project to an update structure and render the edit form + return renderProjectEditForm(io, targetProject.ToUpdate(), nil, nil) + }) +} + +// projectUpdateController processes form submissions for updating existing projects +// This controller validates changes and applies them to the database +func projectUpdateController(appCtx *hctx.AppCtx, webCtx *web.Ctx, service *project.ProjectService) http.Handler { + return web.NewController(appCtx, webCtx, func(io web.IO) error { + ctx := io.Context() + + // Extract project ID from URL parameters + id, err := parseObjectID(io.Request(), "id") + if err != nil { + log.Printf("ERROR: Invalid project ID for update: %v", err) + return io.InlineError(web.ErrInternal, err) + } + + log.Printf("INFO: Updating project with ID %s", id.Hex()) + + // Create an update structure with the project ID + toUpdate := &project.ProjectToUpdate{ID: id} + + // Parse and validate the form data from the request + err, validationErrs := web.ReadForm(io.Request(), toUpdate, appCtx.Validator) + if err != nil { + log.Printf("ERROR: Failed to read update form: %v", err) + return io.InlineError(web.ErrInternal, err) + } + + // If validation fails, redisplay the form with error messages + if validationErrs != nil { + log.Printf("WARN: Project update validation failed: %d errors", len(validationErrs)) + return renderProjectEditForm(io, toUpdate, nil, validationErrs) + } + + log.Printf("INFO: Updating project '%s' with new name '%s'", id.Hex(), toUpdate.Name) + + // Apply the updates through the service layer + updatedProject, err := service.UpdateProject(ctx, toUpdate) + if err != nil { + log.Printf("ERROR: Failed to update project %s: %v", id.Hex(), err) + return renderProjectEditForm(io, toUpdate, nil, []error{validation.Error{Msg: err.Error()}}) + } + + log.Printf("INFO: Project '%s' updated successfully", updatedProject.Name) + + // Redisplay the form with a success message + return renderProjectEditForm(io, updatedProject.ToUpdate(), []string{"Projekt erfolgreich aktualisiert"}, nil) + }) +} + +// projectDeleteController handles project deletion requests +// This controller removes the project and all associated requirements from the database +func projectDeleteController(appCtx *hctx.AppCtx, webCtx *web.Ctx, service *project.ProjectService) http.Handler { + return web.NewController(appCtx, webCtx, func(io web.IO) error { + ctx := io.Context() + usr := user.MustCtxUser(ctx) + + // Parse the project ID from the URL + id, err := parseObjectID(io.Request(), "id") + if err != nil { + log.Printf("ERROR: Invalid project ID for deletion: %v", err) + return io.InlineError(web.ErrInternal, err) + } + + log.Printf("INFO: Deleting project with ID %s for user %s", id.Hex(), usr.ID) + + // Delete the project and all its requirements through the service + err = service.DeleteProject(ctx, id) + if err != nil { + log.Printf("ERROR: Failed to delete project %s: %v", id.Hex(), err) + return io.InlineError(web.ErrInternal, err) + } + + log.Printf("INFO: Project %s deleted successfully", id.Hex()) + + // After deletion, fetch and return the updated project list + // This provides immediate feedback to the user about the deletion + projects, err := service.GetProjectsByUser(ctx, usr.ID) + if err != nil { + log.Printf("ERROR: Failed to get updated project list after deletion: %v", err) + return io.InlineError(web.ErrInternal, err) + } + + log.Printf("INFO: Returning updated project list with %d projects", len(projects)) + + // Return the updated project list as HTML to replace the current list + data := ProjectListData{Projects: projects} + return io.Render( + data, + "project.list", // Template name for just the list part + "project/_list.go.html", // Partial template for project list + ) + }) +} + +// projectSearchController handles search requests for projects +// This controller filters projects based on user input and returns matching results +func projectSearchController(appCtx *hctx.AppCtx, webCtx *web.Ctx, service *project.ProjectService) http.Handler { + return web.NewController(appCtx, webCtx, func(io web.IO) error { + ctx := io.Context() + usr := user.MustCtxUser(ctx) + + request := io.Request() + + // Parse the form data to extract search parameters + err := request.ParseForm() + if err != nil { + log.Printf("ERROR: Failed to parse search form: %v", err) + return io.InlineError(web.ErrInternal, err) + } + + // Extract the search query from the form + query := request.FormValue("search") + log.Printf("INFO: Searching projects for user %s with query '%s'", usr.ID, query) + + // Perform the search using the service layer + // The service handles both empty queries (return all) and actual searches + projects, err := service.SearchProjects(ctx, query, usr.ID) + if err != nil { + log.Printf("ERROR: Project search failed for query '%s': %v", query, err) + return io.InlineError(web.ErrInternal, err) + } + + log.Printf("INFO: Search found %d projects for query '%s'", len(projects), query) + + // Return the search results as a partial HTML update + data := ProjectListData{Projects: projects} + return io.Render( + data, + "project.list", // Template name for the list + "templates/project/_list.go.html", // Partial template file + ) + }) +} + +// Helper Functions + +// parseObjectID extracts and validates a MongoDB ObjectID from HTTP request parameters +// This function ensures that URL parameters containing IDs are valid MongoDB ObjectIDs +func parseObjectID(r *http.Request, param string) (primitive.ObjectID, error) { + // Extract the parameter value from the URL + idStr := web.URLParam(r, param) + if idStr == "" { + return primitive.NilObjectID, errors.New("missing id parameter") + } + + // Convert the string to a valid MongoDB ObjectID + return primitive.ObjectIDFromHex(idStr) +} + +// renderProjectForm is a helper function to render project creation/edit forms +// This function handles both success messages and validation errors +func renderProjectForm(io web.IO, data *ProjectFormData, success []string, errs []error) error { + log.Printf("DEBUG: Rendering project form (edit=%t, errors=%d)", data.IsEditForm, len(errs)) + + // Wrap the form data with success messages and errors for template rendering + err := io.Render( + web.NewFormData(data, success, errs...), + "project.form.page", // Main template name + "project/form-page.go.html", // Page wrapper template + "project/_form.go.html", // Form partial template + ) + + if err != nil { + log.Printf("ERROR: Failed to render project form: %v", err) + } + + return err +} + +// renderProjectEditForm is a specialized helper for rendering edit forms +// This function prepares edit-specific form data and renders the appropriate templates +func renderProjectEditForm(io web.IO, toUpdate *project.ProjectToUpdate, success []string, errs []error) error { + // Create form data structure specifically for editing + formData := &ProjectFormData{ + Project: toUpdate, + IsEditForm: true, // This flag helps the template render edit-specific elements + } + + log.Printf("DEBUG: Rendering project edit form for project %s (errors=%d)", toUpdate.ID.Hex(), len(errs)) + + // Render the edit form with the current project data + err := io.Render( + web.NewFormData(formData, success, errs...), + "project.edit.form", // Template name for edit forms + "project/_form.go.html", // Shared form partial template + ) + + if err != nil { + log.Printf("ERROR: Failed to render project edit form: %v", err) + } + + return err +}