2 Commits

Author SHA1 Message Date
87cb12906d docs: add release notes for v1.3.2 2026-02-19 18:43:03 +03:00
075fc709dd Harden local config updates and error logging 2026-02-19 18:41:45 +03:00
4 changed files with 241 additions and 9 deletions

View File

@@ -1079,7 +1079,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
config, err := configService.UpdateNoAuth(uuid, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
switch {
case errors.Is(err, services.ErrConfigNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
@@ -1747,11 +1756,37 @@ func requestLogger() gin.HandlerFunc {
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
blw := &captureResponseWriter{
ResponseWriter: c.Writer,
body: bytes.NewBuffer(nil),
}
c.Writer = blw
c.Next()
latency := time.Since(start)
status := c.Writer.Status()
if status >= http.StatusBadRequest {
responseBody := strings.TrimSpace(blw.body.String())
if len(responseBody) > 2048 {
responseBody = responseBody[:2048] + "...(truncated)"
}
errText := strings.TrimSpace(c.Errors.String())
slog.Error("request failed",
"method", c.Request.Method,
"path", path,
"query", query,
"status", status,
"latency", latency,
"ip", c.ClientIP(),
"errors", errText,
"response", responseBody,
)
return
}
slog.Info("request",
"method", c.Request.Method,
"path", path,
@@ -1762,3 +1797,22 @@ func requestLogger() gin.HandlerFunc {
)
}
}
type captureResponseWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w *captureResponseWriter) Write(b []byte) (int, error) {
if len(b) > 0 {
_, _ = w.body.Write(b)
}
return w.ResponseWriter.Write(b)
}
func (w *captureResponseWriter) WriteString(s string) (int, error) {
if s != "" {
_, _ = w.body.WriteString(s)
}
return w.ResponseWriter.WriteString(s)
}

View File

@@ -461,9 +461,21 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
projectUUID := localCfg.ProjectUUID
if req.ProjectUUID != nil {
requestedProjectUUID := strings.TrimSpace(*req.ProjectUUID)
currentProjectUUID := ""
if localCfg.ProjectUUID != nil {
currentProjectUUID = strings.TrimSpace(*localCfg.ProjectUUID)
}
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
if err != nil {
return nil, err
// Allow save for legacy/orphaned configs when request keeps the same project UUID.
// This can happen for imported configs whose project is not present in local cache.
if errors.Is(err, ErrProjectNotFound) && requestedProjectUUID != "" && requestedProjectUUID == currentProjectUUID {
projectUUID = localCfg.ProjectUUID
} else {
return nil, err
}
}
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
@@ -1001,13 +1013,17 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
return fmt.Errorf("load current version before save: %w", err)
}
sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion)
if err != nil {
return fmt.Errorf("compare revision content: %w", err)
}
if sameRevisionContent {
cfg = localdb.LocalToConfiguration(&locked)
return nil
// Legacy/orphaned rows may have empty or stale current_version_id.
// In that case we treat update as content-changing and append a fresh version.
if currentVersion != nil {
sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion)
if err != nil {
return fmt.Errorf("compare revision content: %w", err)
}
if sameRevisionContent {
cfg = localdb.LocalToConfiguration(&locked)
return nil
}
}
}
@@ -1058,6 +1074,9 @@ func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
Order("version_no DESC").
First(&version).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &version, nil

View File

@@ -290,6 +290,99 @@ func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
}
}
func TestUpdateNoAuthAllowsOrphanProjectWhenUUIDUnchanged(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
project := &localdb.LocalProject{
UUID: "project-orphan",
OwnerUsername: "tester",
Code: "TEST-ORPHAN",
Name: ptrString("Orphan Project"),
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SyncStatus: "synced",
}
if err := local.SaveProject(project); err != nil {
t.Fatalf("save project: %v", err)
}
created, err := service.Create("tester", &CreateConfigRequest{
Name: "cfg",
ProjectUUID: &project.UUID,
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
// Simulate missing project in local cache while config still references its UUID.
if err := local.DB().Where("uuid = ?", project.UUID).Delete(&localdb.LocalProject{}).Error; err != nil {
t.Fatalf("delete project: %v", err)
}
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
Name: "cfg-updated",
ProjectUUID: &project.UUID,
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("update config with orphan project_uuid: %v", err)
}
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID)
}
}
func TestUpdateNoAuthRecoversWhenCurrentVersionMissing(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
created, err := service.Create("tester", &CreateConfigRequest{
Name: "cfg",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
// Simulate corrupted/legacy versioning state:
// local configuration exists, but all version rows are gone and pointer is stale.
if err := local.DB().Where("configuration_uuid = ?", created.UUID).
Delete(&localdb.LocalConfigurationVersion{}).Error; err != nil {
t.Fatalf("delete versions: %v", err)
}
staleID := "missing-version-id"
if err := local.DB().Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", created.UUID).
Update("current_version_id", staleID).Error; err != nil {
t.Fatalf("set stale current_version_id: %v", err)
}
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
Name: "cfg-updated",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("update config with missing current version: %v", err)
}
if updated.Name != "cfg-updated" {
t.Fatalf("expected updated name, got %q", updated.Name)
}
versions := loadVersions(t, local, created.UUID)
if len(versions) != 1 {
t.Fatalf("expected 1 recreated version, got %d", len(versions))
}
if versions[0].VersionNo != 1 {
t.Fatalf("expected recreated version_no=1, got %d", versions[0].VersionNo)
}
}
func ptrString(value string) *string {
return &value
}

66
releases/memory/v1.3.2.md Normal file
View File

@@ -0,0 +1,66 @@
# Release v1.3.2 (2026-02-19)
## Summary
Release focuses on stability and data integrity for local configurations. Added configuration revision history, stronger recovery for broken local sync/version states, improved sync self-healing, and clearer API error logging.
## Changes
### Configuration Revisions
- Added full local configuration revision flow with storage and UI support.
- Introduced revisions page/template and backend plumbing for browsing revisions.
- Prevented duplicate revisions when content did not actually change.
### Local Data Integrity and Recovery
- Added migration and snapshot support for local configuration version data.
- Hardened updates for legacy/orphaned configuration rows:
- allow update when project UUID is unchanged even if referenced project is missing locally;
- recover gracefully when `current_version_id` is stale or version rows are missing.
- Added regression tests for orphan-project and missing-current-version scenarios.
### Sync Reliability
- Added smart self-healing path for sync errors.
- Fixed duplicate-project sync edge cases.
### API and Logging
- Improved HTTP error mapping for configuration updates (`404/403` instead of generic `500` in known cases).
- Enhanced request logger to capture error responses (status, response body snippet, gin errors) for failed requests.
### UI and Export
- Updated project detail and index templates for revisions and related UX improvements.
- Updated export pipeline and tests to align with revisions/project behavior changes.
## Breaking Changes
None identified.
## Files Changed
- 24 files changed, 2394 insertions(+), 482 deletions(-)
- Main touched areas:
- `/Users/mchusavitin/Documents/git/QuoteForge/internal/services/local_configuration.go`
- `/Users/mchusavitin/Documents/git/QuoteForge/internal/services/local_configuration_versioning_test.go`
- `/Users/mchusavitin/Documents/git/QuoteForge/internal/localdb/{localdb.go,migrations.go,snapshots.go,local_migrations_test.go}`
- `/Users/mchusavitin/Documents/git/QuoteForge/internal/services/export.go`
- `/Users/mchusavitin/Documents/git/QuoteForge/cmd/qfs/main.go`
- `/Users/mchusavitin/Documents/git/QuoteForge/web/templates/{config_revisions.html,project_detail.html,index.html,base.html}`
## Commits Included (`v1.3.1..v1.3.2`)
- `b153afb` - Add smart self-healing for sync errors
- `8508ee2` - Fix sync errors for duplicate projects and add modal scrolling
- `2e973b6` - Add configuration revisions system and project variant deletion
- `71f73e2` - chore: save current changes
- `cbaeafa` - Deduplicate configuration revisions and update revisions UI
- `075fc70` - Harden local config updates and error logging
## Testing
- [x] Targeted tests for local configuration update/version recovery:
- `go test ./internal/services -run 'TestUpdateNoAuth(AllowsOrphanProjectWhenUUIDUnchanged|RecoversWhenCurrentVersionMissing|KeepsProjectWhenProjectUUIDOmitted)$'`
- [ ] Full regression suite not run in this release step.