Compare commits
2 Commits
cbaeafa9c8
...
87cb12906d
| Author | SHA1 | Date | |
|---|---|---|---|
| 87cb12906d | |||
| 075fc709dd |
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
66
releases/memory/v1.3.2.md
Normal 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.
|
||||
Reference in New Issue
Block a user