package repository import ( "database/sql" "errors" "fmt" "time" domain "github.com/haydenhargreaves/Potion/internal/domain/engagement" _ "github.com/lib/pq" ) type EngagementRepository struct { db *sql.DB } // Compile-time check to ensure the EngagementRepository implements domain.EngagementRepository var _ domain.EngagementRepository = (*EngagementRepository)(nil) // NewUserRepository creates a user repository object which is used by the user service to access // the database. Any user related database operations will take place in this repository. func NewEngagementRepository(db *sql.DB) domain.EngagementRepository { return &EngagementRepository{db: db} } // AddUserEngagement creates an engagement record in the database with the user ID provided. This // function does not accept an entity ID as it should be used when there is no need to reference // an entity. The message should be provided, but a blank string ("") is acceptable. The engagement // type parameter determines the labeling of the engagement in the database. Any errors will be // bubbled to the caller. func (r *EngagementRepository) AddUserEngagement(userId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) { tx, err := r.db.Begin() if err != nil { return domain.Engagement{}, err } query := ` INSERT INTO Engagements ( type, message, entity, userid, created ) VALUES ( $1, $2, NULL, $3, $4 ) RETURNING *; ` var engagement domain.Engagement var engUserId sql.NullInt32 if err := tx.QueryRow(query, engagementType, message, userId, time.Now()).Scan( &engagement.Id, &engagement.Type, &engagement.Message, &engagement.Entity, &engUserId, &engagement.Created, ); err != nil { tx.Rollback() return domain.Engagement{}, fmt.Errorf("Failed to insert engagement into database. %s", err.Error()) } if err := tx.Commit(); err != nil { return domain.Engagement{}, err } // Is user is valid if engUserId.Valid { engagement.UserId = int(engUserId.Int32) } return engagement, nil } // AddUserEntityEngagement creates an engagement record in the database with the user ID provided. This // function requires an entity ID as it should be used when there is a reference to external an // entity. The message should be provided, but a blank string ("") is acceptable. The engagement // type parameter determines the labeling of the engagement in the database. Any errors will be // bubbled to the caller. // // TODO: Disallow users to "make" the same recipe more than once a day func (r *EngagementRepository) AddUserEntityEngagement(userId, entityId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) { tx, err := r.db.Begin() if err != nil { return domain.Engagement{}, err } query := ` INSERT INTO Engagements ( type, message, entity, userid, created ) VALUES ( $1, $2, $3, $4, $5 ) RETURNING *; ` var engagement domain.Engagement var engUserId sql.NullInt32 if err := tx.QueryRow(query, engagementType, message, entityId, userId, time.Now()).Scan( &engagement.Id, &engagement.Type, &engagement.Message, &engagement.Entity, &engUserId, &engagement.Created, ); err != nil { tx.Rollback() return domain.Engagement{}, fmt.Errorf("Failed to insert engagement into database. %s", err.Error()) } if err := tx.Commit(); err != nil { return domain.Engagement{}, err } // Is user is valid if engUserId.Valid { engagement.UserId = int(engUserId.Int32) } return engagement, nil } // AddEngagement creates an engagement record in the database without any user. This function does // not accept an entity ID as it should be used when there is no need to reference an entity or user. // The message should be provided, but a blank string ("") is acceptable. The engagement type // parameter determines the labeling of the engagement in the database. Any errors will be bubbled // to the caller. // // List of allowed engagements: viewed, shared func (r *EngagementRepository) AddEngagement(message string, engagementType domain.EngagementType) (domain.Engagement, error) { // Prevent invalid engagement types switch engagementType { case domain.EngagementViewed: case domain.EngagementShared: break case domain.EngagementMade: case domain.EngagementLiked: case domain.EngagementReviewed: case domain.EngagementRated: return domain.Engagement{}, fmt.Errorf("Attempting to use disallowed anonymous engagement type.") } tx, err := r.db.Begin() if err != nil { return domain.Engagement{}, err } query := ` INSERT INTO Engagements ( type, message, entity, userid, created ) VALUES ( $1, $2, NULL, NULL, $3 ) RETURNING *; ` var engagement domain.Engagement var userId sql.NullInt32 if err := tx.QueryRow(query, engagementType, message, time.Now()).Scan( &engagement.Id, &engagement.Type, &engagement.Message, &engagement.Entity, &userId, &engagement.Created, ); err != nil { tx.Rollback() return domain.Engagement{}, fmt.Errorf("Failed to insert engagement into database. %s", err.Error()) } if err := tx.Commit(); err != nil { return domain.Engagement{}, err } // Is user is valid if userId.Valid { engagement.UserId = int(userId.Int32) } return engagement, nil } // AddEntityEngagement creates an engagement record in the database without any user. This function // requires an entity ID as it should be used when there is a reference to external an entity. // The message should be provided, but a blank string ("") is acceptable. The engagement type // parameter determines the labeling of the engagement in the database. Any errors will be // bubbled to the caller. // // List of allowed engagements: viewed, shared func (r *EngagementRepository) AddEntityEngagement(entityId int, message string, engagementType domain.EngagementType) (domain.Engagement, error) { // Prevent invalid engagement types switch engagementType { case domain.EngagementViewed: case domain.EngagementShared: break case domain.EngagementMade: case domain.EngagementLiked: case domain.EngagementReviewed: case domain.EngagementRated: return domain.Engagement{}, fmt.Errorf("Attempting to use disallowed anonymous engagement type.") } tx, err := r.db.Begin() if err != nil { return domain.Engagement{}, err } query := ` INSERT INTO Engagements ( type, message, entity, userid, created ) VALUES ( $1, $2, $3, NULL, $4 ) RETURNING *; ` var engagement domain.Engagement var userId sql.NullInt32 if err := tx.QueryRow(query, engagementType, message, entityId, time.Now()).Scan( &engagement.Id, &engagement.Type, &engagement.Message, &engagement.Entity, &userId, &engagement.Created, ); err != nil { tx.Rollback() return domain.Engagement{}, fmt.Errorf("Failed to insert engagement into database. %s", err.Error()) } if err := tx.Commit(); err != nil { return domain.Engagement{}, err } // Is user is valid if userId.Valid { engagement.UserId = int(userId.Int32) } return engagement, nil } // GetUserEngagement returns a list of the users most recent engagement entries. The number of records // is determined by the limit passed into this function. The results are sorted, newest-to-oldest. func (r *EngagementRepository) GetUserEngagement(userId, limit int) ([]domain.Engagement, error) { query := ` SELECT * FROM Engagements WHERE Userid = $1 ORDER BY created DESC LIMIT $2; ` rows, err := r.db.Query(query, userId, limit) if err != nil { return []domain.Engagement{}, fmt.Errorf("Failed to get user engagements. %s", err.Error()) } defer rows.Close() var engagements []domain.Engagement for rows.Next() { var engagement domain.Engagement var engUserId sql.NullInt32 if err := rows.Scan( &engagement.Id, &engagement.Type, &engagement.Message, &engagement.Entity, &engUserId, &engagement.Created, ); err != nil { return []domain.Engagement{}, fmt.Errorf("Failed to scan user engagement. %s", err.Error()) } // Add user if valid if engUserId.Valid { engagement.UserId = int(engUserId.Int32) } engagements = append(engagements, engagement) } return engagements, err } // GetUserEngagementFiltered returns a list of the users most recent engagement entries of a provided // type. The number of records is determined by the limit passed into this function. The results are // sorted, newest-to-oldest. Only results of the provided engagementType will be returned. func (r *EngagementRepository) GetUserEngagementFiltered(userId, limit int, engagementType domain.EngagementType) ([]domain.Engagement, error) { query := ` SELECT id, type, message, entity, userid, created FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY entity ORDER BY created DESC) as rn FROM Engagements WHERE Userid = $1 AND type = $2 ) AS subquery WHERE rn = 1 ORDER BY created DESC LIMIT $3; ` rows, err := r.db.Query(query, userId, engagementType, limit) if err != nil { return []domain.Engagement{}, fmt.Errorf("Failed to get user engagements. %s", err.Error()) } defer rows.Close() var engagements []domain.Engagement for rows.Next() { var engagement domain.Engagement var engUserId sql.NullInt32 if err := rows.Scan( &engagement.Id, &engagement.Type, &engagement.Message, &engagement.Entity, &engUserId, &engagement.Created, ); err != nil { return []domain.Engagement{}, fmt.Errorf("Failed to scan user engagement. %s", err.Error()) } // Add user if valid if engUserId.Valid { engagement.UserId = int(engUserId.Int32) } engagements = append(engagements, engagement) } return engagements, err } // UserFavoriteRecipeToggle toggles the status of a users favorite of a recipe. If the user has already // favorited the provided recipe, the database entry will be delete, hence removing the favorite. Otherwise, // an entry will be created. The NEW status of the users favorite will be returned as the boolean. Any // errors will be bubbled to the caller. func (r *EngagementRepository) UserFavoriteRecipeToggle(userId, recipeId int) (bool, error) { tx, err := r.db.Begin() if err != nil { return false, err } query := ` SELECT id FROM favorites WHERE userid = $1 AND recipeid = $2 ` var id int err = tx.QueryRow(query, userId, recipeId).Scan(&id) if err != nil { if !errors.Is(err, sql.ErrNoRows) { tx.Rollback() return false, fmt.Errorf("Failed to get recipe favorite. %s", err.Error()) } } // Means we should create var success bool if id == 0 { createQuery := "INSERT INTO favorites (userid, recipeid, created) VALUES ($1, $2, $3);" if result, err := tx.Exec(createQuery, userId, recipeId, time.Now()); err != nil { tx.Rollback() return false, fmt.Errorf("Failed to create recipe favorite. %s", err.Error()) } else { rows, _ := result.RowsAffected() success = rows == 1 } } else { deleteQuery := "DELETE FROM favorites WHERE id = $1;" if result, err := tx.Exec(deleteQuery, id); err != nil { tx.Rollback() return false, fmt.Errorf("Failed to remove recipe favorite. %s", err.Error()) } else { rows, _ := result.RowsAffected() success = rows == 1 } } if err := tx.Commit(); err != nil { tx.Rollback() return false, err } return success, nil }