diff --git a/src/Project/repository.go b/src/Project/repository.go index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..164ff09bcff81560690b25aac0b4fad14d1f55a8 100644 --- a/src/Project/repository.go +++ b/src/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() +}