diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cc4ffd..c7b0094 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,3 @@ - # Changelog All notable changes to this project will be documented in this file. @@ -6,6 +5,26 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +## [v2.0.1] - 2025-09-07 + +### Hotfixes + +- Ensure reproduction rate always appears in json response even when null - fix rt values to always be included in api responses for consistency - update reproductionrate struct to use pointer types for proper null handling - modify transformation logic to always include rt structure - update tests to handle new pointer-based rt values - bump version to 2.0.1 for hotfix release this ensures api consumers always receive the reproduction_rate object structure, with null values when data is not available. + +### Fixed + +- Correct database column typo and ensure rt fields always present in json response - fix database column name typo from 'cumulative_finished_persoon_under_observation' to 'cumulative_finished_person_under_observation' - remove 'omitempty' from rt fields (rt, rt_upper, rt_lower) to ensure they always appear in json responses even when null - update all sql queries and tests to use correct column name - critical production hotfix for database errors and missing rt data +- Correct [Unreleased] section format in CHANGELOG.md + +### Added + +- Add hotfix branch support to changelog generator + +### Maintenance + +- Bump version to 2.0.1 for hotfix release (version) ## [v2.0.0] - 2025-09-07 diff --git a/generate-changelog.rb b/generate-changelog.rb index e269364..636c381 100755 --- a/generate-changelog.rb +++ b/generate-changelog.rb @@ -20,7 +20,7 @@ # - Cross-platform compatibility (Ruby vs. bash-specific features) # # Features: -# - Only runs from release branches (release/vx.x.x) +# - Only runs from release or hotfix branches (release/vx.x.x or hotfix/vx.x.x) # - Categorizes commits by conventional commit types # - Determines semantic version increment automatically # - Updates CHANGELOG.md with proper formatting @@ -38,6 +38,7 @@ class ChangelogGenerator COMMIT_CATEGORIES = { 'feat' => { category: 'Added', breaking: false }, 'fix' => { category: 'Fixed', breaking: false }, + 'hotfix' => { category: 'Hotfixes', breaking: false }, 'docs' => { category: 'Documentation', breaking: false }, 'style' => { category: 'Style', breaking: false }, 'refactor' => { category: 'Changed', breaking: false }, @@ -49,8 +50,9 @@ class ChangelogGenerator 'revert' => { category: 'Reverted', breaking: false } }.freeze - # Release branch pattern + # Release and hotfix branch patterns RELEASE_BRANCH_PATTERN = /^release\/v(\d+)\.(\d+)\.(\d+)$/ + HOTFIX_BRANCH_PATTERN = /^hotfix\/v(\d+)\.(\d+)\.(\d+)$/ # Changelog file path CHANGELOG_PATH = 'CHANGELOG.md' @@ -128,10 +130,13 @@ def get_current_branch # Parse version information from the current branch name # # @return [Hash] Version components (major, minor, patch) - # @raise [RuntimeError] if not on a valid release branch + # @raise [RuntimeError] if not on a valid release or hotfix branch def parse_version_from_branch - match = current_branch.match(RELEASE_BRANCH_PATTERN) - raise "Not on a release branch. Expected format: release/vX.Y.Z" unless match + release_match = current_branch.match(RELEASE_BRANCH_PATTERN) + hotfix_match = current_branch.match(HOTFIX_BRANCH_PATTERN) + match = release_match || hotfix_match + + raise "Not on a release or hotfix branch. Expected format: release/vX.Y.Z or hotfix/vX.Y.Z" unless match { major: match[1].to_i, @@ -148,6 +153,14 @@ def version_string "v#{version_info[:major]}.#{version_info[:minor]}.#{version_info[:patch]}" end + ## + # Check if we're currently on a hotfix branch + # + # @return [Boolean] true if on hotfix branch, false if on release branch + def hotfix_branch? + current_branch.match?(HOTFIX_BRANCH_PATTERN) + end + ## # Validate the environment before proceeding # @@ -296,19 +309,20 @@ def should_skip_commit?(type, commit) def category_priority(category) priorities = { 'Breaking Changes' => 1, - 'Added' => 2, - 'Changed' => 3, - 'Fixed' => 4, - 'Deprecated' => 5, - 'Removed' => 6, - 'Security' => 7, - 'Performance' => 8, - 'Documentation' => 9, - 'Tests' => 10, - 'CI/CD' => 11, - 'Build' => 12, - 'Maintenance' => 13, - 'Other' => 14 + 'Hotfixes' => 2, + 'Added' => 3, + 'Changed' => 4, + 'Fixed' => 5, + 'Deprecated' => 6, + 'Removed' => 7, + 'Security' => 8, + 'Performance' => 9, + 'Documentation' => 10, + 'Tests' => 11, + 'CI/CD' => 12, + 'Build' => 13, + 'Maintenance' => 14, + 'Other' => 15 } priorities[category] || 99 end @@ -452,7 +466,7 @@ def self.run(args = ARGV) opts.separator "following conventional commit format and Keep a Changelog style." opts.separator "" opts.separator "Requirements:" - opts.separator "- Must be run from a release branch (release/vX.Y.Z)" + opts.separator "- Must be run from a release or hotfix branch (release/vX.Y.Z or hotfix/vX.Y.Z)" opts.separator "- Git repository with existing tags" opts.separator "- CHANGELOG.md file with [Unreleased] section" opts.separator "" diff --git a/internal/handler/covid_handler.go b/internal/handler/covid_handler.go index e8e3d79..fa09bde 100644 --- a/internal/handler/covid_handler.go +++ b/internal/handler/covid_handler.go @@ -137,7 +137,7 @@ func (h *CovidHandler) HealthCheck(w http.ResponseWriter, r *http.Request) { health := map[string]interface{}{ "status": "healthy", "service": "COVID-19 API", - "version": "2.0.0", + "version": "2.0.1", "timestamp": time.Now().UTC().Format(time.RFC3339), } diff --git a/internal/handler/covid_handler_test.go b/internal/handler/covid_handler_test.go index ffb9625..421f175 100644 --- a/internal/handler/covid_handler_test.go +++ b/internal/handler/covid_handler_test.go @@ -286,7 +286,7 @@ func TestCovidHandler_HealthCheck(t *testing.T) { assert.True(t, ok) assert.Equal(t, "degraded", data["status"]) assert.Equal(t, "COVID-19 API", data["service"]) - assert.Equal(t, "2.0.0", data["version"]) + assert.Equal(t, "2.0.1", data["version"]) assert.Contains(t, data, "database") dbData, ok := data["database"].(map[string]interface{}) diff --git a/internal/models/national_case.go b/internal/models/national_case.go index 0e3a5fa..4465020 100644 --- a/internal/models/national_case.go +++ b/internal/models/national_case.go @@ -16,9 +16,9 @@ type NationalCase struct { CumulativePositive int64 `json:"cumulative_positive" db:"cumulative_positive"` CumulativeRecovered int64 `json:"cumulative_recovered" db:"cumulative_recovered"` CumulativeDeceased int64 `json:"cumulative_deceased" db:"cumulative_deceased"` - Rt *float64 `json:"rt,omitempty" db:"rt"` - RtUpper *float64 `json:"rt_upper,omitempty" db:"rt_upper"` - RtLower *float64 `json:"rt_lower,omitempty" db:"rt_lower"` + Rt *float64 `json:"rt" db:"rt"` + RtUpper *float64 `json:"rt_upper" db:"rt_upper"` + RtLower *float64 `json:"rt_lower" db:"rt_lower"` } type NullFloat64 struct { diff --git a/internal/models/national_case_response.go b/internal/models/national_case_response.go index 06a2b40..3b29586 100644 --- a/internal/models/national_case_response.go +++ b/internal/models/national_case_response.go @@ -42,9 +42,9 @@ type CasePercentages struct { // ReproductionRate represents the R-value with confidence bounds type ReproductionRate struct { - Value float64 `json:"value"` - UpperBound float64 `json:"upper_bound"` - LowerBound float64 `json:"lower_bound"` + Value *float64 `json:"value"` + UpperBound *float64 `json:"upper_bound"` + LowerBound *float64 `json:"lower_bound"` } // TransformToResponse converts a NationalCase model to the response format @@ -74,13 +74,11 @@ func (nc *NationalCase) TransformToResponse() NationalCaseResponse { }, } - // Add reproduction rate if available - if nc.Rt != nil && nc.RtUpper != nil && nc.RtLower != nil { - response.Statistics.ReproductionRate = &ReproductionRate{ - Value: *nc.Rt, - UpperBound: *nc.RtUpper, - LowerBound: *nc.RtLower, - } + // Always include reproduction rate structure, even when values are null + response.Statistics.ReproductionRate = &ReproductionRate{ + Value: nc.Rt, + UpperBound: nc.RtUpper, + LowerBound: nc.RtLower, } return response diff --git a/internal/models/national_case_response_test.go b/internal/models/national_case_response_test.go index 27ec345..bf01d90 100644 --- a/internal/models/national_case_response_test.go +++ b/internal/models/national_case_response_test.go @@ -69,14 +69,14 @@ func TestNationalCase_TransformToResponse(t *testing.T) { if response.Statistics.ReproductionRate == nil { t.Error("Expected ReproductionRate to be present") } else { - if response.Statistics.ReproductionRate.Value != rtValue { - t.Errorf("Expected Rt.Value %f, got %f", rtValue, response.Statistics.ReproductionRate.Value) + if *response.Statistics.ReproductionRate.Value != rtValue { + t.Errorf("Expected Rt.Value %f, got %f", rtValue, *response.Statistics.ReproductionRate.Value) } - if response.Statistics.ReproductionRate.UpperBound != rtUpper { - t.Errorf("Expected Rt.UpperBound %f, got %f", rtUpper, response.Statistics.ReproductionRate.UpperBound) + if *response.Statistics.ReproductionRate.UpperBound != rtUpper { + t.Errorf("Expected Rt.UpperBound %f, got %f", rtUpper, *response.Statistics.ReproductionRate.UpperBound) } - if response.Statistics.ReproductionRate.LowerBound != rtLower { - t.Errorf("Expected Rt.LowerBound %f, got %f", rtLower, response.Statistics.ReproductionRate.LowerBound) + if *response.Statistics.ReproductionRate.LowerBound != rtLower { + t.Errorf("Expected Rt.LowerBound %f, got %f", rtLower, *response.Statistics.ReproductionRate.LowerBound) } } @@ -103,8 +103,18 @@ func TestNationalCase_TransformToResponse_NoReproductionRate(t *testing.T) { response := nc.TransformToResponse() - if response.Statistics.ReproductionRate != nil { - t.Error("Expected ReproductionRate to be nil when not provided") + if response.Statistics.ReproductionRate == nil { + t.Error("Expected ReproductionRate to always be present") + } else { + if response.Statistics.ReproductionRate.Value != nil { + t.Error("Expected Rt.Value to be nil when not provided") + } + if response.Statistics.ReproductionRate.UpperBound != nil { + t.Error("Expected Rt.UpperBound to be nil when not provided") + } + if response.Statistics.ReproductionRate.LowerBound != nil { + t.Error("Expected Rt.LowerBound to be nil when not provided") + } } } diff --git a/internal/models/province_case.go b/internal/models/province_case.go index b1d9171..456928c 100644 --- a/internal/models/province_case.go +++ b/internal/models/province_case.go @@ -17,12 +17,12 @@ type ProvinceCase struct { CumulativeRecovered int64 `json:"cumulative_recovered" db:"cumulative_recovered"` CumulativeDeceased int64 `json:"cumulative_deceased" db:"cumulative_deceased"` CumulativePersonUnderObservation int64 `json:"cumulative_person_under_observation" db:"cumulative_person_under_observation"` - CumulativeFinishedPersonUnderObservation int64 `json:"cumulative_finished_person_under_observation" db:"cumulative_finished_persoon_under_observation"` + CumulativeFinishedPersonUnderObservation int64 `json:"cumulative_finished_person_under_observation" db:"cumulative_finished_person_under_observation"` CumulativePersonUnderSupervision int64 `json:"cumulative_person_under_supervision" db:"cumulative_person_under_supervision"` CumulativeFinishedPersonUnderSupervision int64 `json:"cumulative_finished_person_under_supervision" db:"cumulative_finished_person_under_supervision"` - Rt *float64 `json:"rt,omitempty" db:"rt"` - RtUpper *float64 `json:"rt_upper,omitempty" db:"rt_upper"` - RtLower *float64 `json:"rt_lower,omitempty" db:"rt_lower"` + Rt *float64 `json:"rt" db:"rt"` + RtUpper *float64 `json:"rt_upper" db:"rt_upper"` + RtLower *float64 `json:"rt_lower" db:"rt_lower"` Province *Province `json:"province,omitempty"` } diff --git a/internal/models/province_case_response.go b/internal/models/province_case_response.go index dfc6994..e802282 100644 --- a/internal/models/province_case_response.go +++ b/internal/models/province_case_response.go @@ -41,7 +41,7 @@ type ProvinceCumulativeCases struct { // ProvinceCaseStatistics contains calculated statistics and metrics for province data type ProvinceCaseStatistics struct { Percentages CasePercentages `json:"percentages"` - ReproductionRate *ReproductionRate `json:"reproduction_rate,omitempty"` + ReproductionRate *ReproductionRate `json:"reproduction_rate"` } // TransformToResponse converts a ProvinceCase model to the response format @@ -86,13 +86,11 @@ func (pc *ProvinceCase) TransformToResponse(date time.Time) ProvinceCaseResponse Province: pc.Province, } - // Add reproduction rate if available - if pc.Rt != nil && pc.RtUpper != nil && pc.RtLower != nil { - response.Statistics.ReproductionRate = &ReproductionRate{ - Value: *pc.Rt, - UpperBound: *pc.RtUpper, - LowerBound: *pc.RtLower, - } + // Always include reproduction rate structure, even when values are null + response.Statistics.ReproductionRate = &ReproductionRate{ + Value: pc.Rt, + UpperBound: pc.RtUpper, + LowerBound: pc.RtLower, } return response diff --git a/internal/models/province_case_response_test.go b/internal/models/province_case_response_test.go index d4abd2d..3f362b1 100644 --- a/internal/models/province_case_response_test.go +++ b/internal/models/province_case_response_test.go @@ -80,9 +80,9 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Deceased: 6.0, // (300 / 5000) * 100 }, ReproductionRate: &ReproductionRate{ - Value: 1.5, - UpperBound: 1.8, - LowerBound: 1.2, + Value: &[]float64{1.5}[0], + UpperBound: &[]float64{1.8}[0], + LowerBound: &[]float64{1.2}[0], }, }, Province: &Province{ @@ -151,7 +151,11 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Recovered: 90.0, // (1800 / 2000) * 100 Deceased: 5.0, // (100 / 2000) * 100 }, - ReproductionRate: nil, + ReproductionRate: &ReproductionRate{ + Value: nil, + UpperBound: nil, + LowerBound: nil, + }, }, Province: &Province{ ID: "ID-JB", @@ -219,7 +223,11 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Recovered: 0.0, Deceased: 0.0, }, - ReproductionRate: nil, + ReproductionRate: &ReproductionRate{ + Value: nil, + UpperBound: nil, + LowerBound: nil, + }, }, Province: &Province{ ID: "ID-AC", @@ -307,9 +315,9 @@ func TestProvinceCaseWithDate_TransformToResponse(t *testing.T) { Deceased: 6.666666666666667, // (200 / 3000) * 100 }, ReproductionRate: &ReproductionRate{ - Value: 1.2, - UpperBound: 1.5, - LowerBound: 0.9, + Value: &[]float64{1.2}[0], + UpperBound: &[]float64{1.5}[0], + LowerBound: &[]float64{0.9}[0], }, }, Province: &Province{ diff --git a/internal/repository/province_case_repository.go b/internal/repository/province_case_repository.go index 378f2fe..06a8687 100644 --- a/internal/repository/province_case_repository.go +++ b/internal/repository/province_case_repository.go @@ -30,7 +30,7 @@ func (r *provinceCaseRepository) GetAll() ([]models.ProvinceCaseWithDate, error) pc.person_under_observation, pc.finished_person_under_observation, pc.person_under_supervision, pc.finished_person_under_supervision, pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, - pc.cumulative_person_under_observation, pc.cumulative_finished_persoon_under_observation, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name FROM province_cases pc @@ -46,7 +46,7 @@ func (r *provinceCaseRepository) GetByProvinceID(provinceID string) ([]models.Pr pc.person_under_observation, pc.finished_person_under_observation, pc.person_under_supervision, pc.finished_person_under_supervision, pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, - pc.cumulative_person_under_observation, pc.cumulative_finished_persoon_under_observation, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name FROM province_cases pc @@ -63,7 +63,7 @@ func (r *provinceCaseRepository) GetByProvinceIDAndDateRange(provinceID string, pc.person_under_observation, pc.finished_person_under_observation, pc.person_under_supervision, pc.finished_person_under_supervision, pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, - pc.cumulative_person_under_observation, pc.cumulative_finished_persoon_under_observation, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name FROM province_cases pc @@ -80,7 +80,7 @@ func (r *provinceCaseRepository) GetByDateRange(startDate, endDate time.Time) ([ pc.person_under_observation, pc.finished_person_under_observation, pc.person_under_supervision, pc.finished_person_under_supervision, pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, - pc.cumulative_person_under_observation, pc.cumulative_finished_persoon_under_observation, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name FROM province_cases pc @@ -97,7 +97,7 @@ func (r *provinceCaseRepository) GetLatestByProvinceID(provinceID string) (*mode pc.person_under_observation, pc.finished_person_under_observation, pc.person_under_supervision, pc.finished_person_under_supervision, pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, - pc.cumulative_person_under_observation, pc.cumulative_finished_persoon_under_observation, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name FROM province_cases pc diff --git a/internal/repository/province_case_repository_test.go b/internal/repository/province_case_repository_test.go index a32160f..a08948d 100644 --- a/internal/repository/province_case_repository_test.go +++ b/internal/repository/province_case_repository_test.go @@ -26,7 +26,7 @@ func TestProvinceCaseRepository_GetAll(t *testing.T) { "person_under_observation", "finished_person_under_observation", "person_under_supervision", "finished_person_under_supervision", "cumulative_positive", "cumulative_recovered", "cumulative_deceased", - "cumulative_person_under_observation", "cumulative_finished_persoon_under_observation", + "cumulative_person_under_observation", "cumulative_finished_person_under_observation", "cumulative_person_under_supervision", "cumulative_finished_person_under_supervision", "rt", "rt_upper", "rt_lower", "date", "name", }).AddRow(1, 1, "11", 50, 40, 2, 10, 8, 5, 3, 500, 400, 20, 100, 80, 50, 30, rt, nil, nil, now, "Aceh") @@ -66,7 +66,7 @@ func TestProvinceCaseRepository_GetByProvinceID(t *testing.T) { "person_under_observation", "finished_person_under_observation", "person_under_supervision", "finished_person_under_supervision", "cumulative_positive", "cumulative_recovered", "cumulative_deceased", - "cumulative_person_under_observation", "cumulative_finished_persoon_under_observation", + "cumulative_person_under_observation", "cumulative_finished_person_under_observation", "cumulative_person_under_supervision", "cumulative_finished_person_under_supervision", "rt", "rt_upper", "rt_lower", "date", "name", }).AddRow(1, 1, provinceID, 50, 40, 2, 10, 8, 5, 3, 500, 400, 20, 100, 80, 50, 30, nil, nil, nil, now, "Aceh") @@ -105,7 +105,7 @@ func TestProvinceCaseRepository_GetByProvinceIDAndDateRange(t *testing.T) { "person_under_observation", "finished_person_under_observation", "person_under_supervision", "finished_person_under_supervision", "cumulative_positive", "cumulative_recovered", "cumulative_deceased", - "cumulative_person_under_observation", "cumulative_finished_persoon_under_observation", + "cumulative_person_under_observation", "cumulative_finished_person_under_observation", "cumulative_person_under_supervision", "cumulative_finished_person_under_supervision", "rt", "rt_upper", "rt_lower", "date", "name", }).AddRow(1, 1, provinceID, 50, 40, 2, 10, 8, 5, 3, 500, 400, 20, 100, 80, 50, 30, nil, nil, nil, now, "Aceh") @@ -142,7 +142,7 @@ func TestProvinceCaseRepository_GetLatestByProvinceID(t *testing.T) { "person_under_observation", "finished_person_under_observation", "person_under_supervision", "finished_person_under_supervision", "cumulative_positive", "cumulative_recovered", "cumulative_deceased", - "cumulative_person_under_observation", "cumulative_finished_persoon_under_observation", + "cumulative_person_under_observation", "cumulative_finished_person_under_observation", "cumulative_person_under_supervision", "cumulative_finished_person_under_supervision", "rt", "rt_upper", "rt_lower", "date", "name", }).AddRow(1, 1, provinceID, 50, 40, 2, 10, 8, 5, 3, 500, 400, 20, 100, 80, 50, 30, rt, nil, nil, now, "Aceh") @@ -178,7 +178,7 @@ func TestProvinceCaseRepository_GetLatestByProvinceID_NotFound(t *testing.T) { "person_under_observation", "finished_person_under_observation", "person_under_supervision", "finished_person_under_supervision", "cumulative_positive", "cumulative_recovered", "cumulative_deceased", - "cumulative_person_under_observation", "cumulative_finished_persoon_under_observation", + "cumulative_person_under_observation", "cumulative_finished_person_under_observation", "cumulative_person_under_supervision", "cumulative_finished_person_under_supervision", "rt", "rt_upper", "rt_lower", "date", "name", })