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)
|
config, err := configService.UpdateNoAuth(uuid, &req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
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()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1747,11 +1756,37 @@ func requestLogger() gin.HandlerFunc {
|
|||||||
path := c.Request.URL.Path
|
path := c.Request.URL.Path
|
||||||
query := c.Request.URL.RawQuery
|
query := c.Request.URL.RawQuery
|
||||||
|
|
||||||
|
blw := &captureResponseWriter{
|
||||||
|
ResponseWriter: c.Writer,
|
||||||
|
body: bytes.NewBuffer(nil),
|
||||||
|
}
|
||||||
|
c.Writer = blw
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|
||||||
latency := time.Since(start)
|
latency := time.Since(start)
|
||||||
status := c.Writer.Status()
|
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",
|
slog.Info("request",
|
||||||
"method", c.Request.Method,
|
"method", c.Request.Method,
|
||||||
"path", path,
|
"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,11 +461,23 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
|
|
||||||
projectUUID := localCfg.ProjectUUID
|
projectUUID := localCfg.ProjectUUID
|
||||||
if req.ProjectUUID != nil {
|
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)
|
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// 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
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1001,6 +1013,9 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return fmt.Errorf("load current version before save: %w", err)
|
return fmt.Errorf("load current version before save: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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)
|
sameRevisionContent, err := s.hasSameRevisionContent(localCfg, currentVersion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("compare revision content: %w", err)
|
return fmt.Errorf("compare revision content: %w", err)
|
||||||
@@ -1010,6 +1025,7 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := tx.Save(localCfg).Error; err != nil {
|
if err := tx.Save(localCfg).Error; err != nil {
|
||||||
return fmt.Errorf("save local configuration: %w", err)
|
return fmt.Errorf("save local configuration: %w", err)
|
||||||
@@ -1058,6 +1074,9 @@ func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *
|
|||||||
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
|
||||||
Order("version_no DESC").
|
Order("version_no DESC").
|
||||||
First(&version).Error; err != nil {
|
First(&version).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &version, nil
|
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 {
|
func ptrString(value string) *string {
|
||||||
return &value
|
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