diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 06e81a9..22d2109 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -996,8 +996,27 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect return } + uuids := make([]string, len(cfgs)) + for i, cfg := range cfgs { + uuids[i] = cfg.UUID + } + viewers, _ := syncService.ListActiveViewersByConfigUUIDs(uuids) + + type cfgRow struct { + models.Configuration + Viewers []string `json:"viewers"` + } + rows := make([]cfgRow, len(cfgs)) + for i, cfg := range cfgs { + v := viewers[cfg.UUID] + if v == nil { + v = []string{} + } + rows[i] = cfgRow{Configuration: cfg, Viewers: v} + } + c.JSON(http.StatusOK, gin.H{ - "configurations": cfgs, + "configurations": rows, "total": total, "page": page, "per_page": perPage, @@ -1332,6 +1351,16 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect } c.JSON(http.StatusOK, config) }) + + configs.POST("/:uuid/presence", func(c *gin.Context) { + _ = local.AddOpenConfigUUID(c.Param("uuid")) + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) + + configs.DELETE("/:uuid/presence", func(c *gin.Context) { + _ = local.RemoveOpenConfigUUID(c.Param("uuid")) + c.JSON(http.StatusOK, gin.H{"ok": true}) + }) } projects := api.Group("/projects") @@ -1672,8 +1701,32 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect } return } + + projUUIDs := make([]string, len(result.Configs)) + for i, cfg := range result.Configs { + projUUIDs[i] = cfg.UUID + } + projViewers, _ := syncService.ListActiveViewersByConfigUUIDs(projUUIDs) + + type projCfgRow struct { + models.Configuration + Viewers []string `json:"viewers"` + } + projRows := make([]projCfgRow, len(result.Configs)) + for i, cfg := range result.Configs { + v := projViewers[cfg.UUID] + if v == nil { + v = []string{} + } + projRows[i] = projCfgRow{Configuration: cfg, Viewers: v} + } + c.Header("X-Config-Status", status) - c.JSON(http.StatusOK, result) + c.JSON(http.StatusOK, gin.H{ + "project_uuid": result.ProjectUUID, + "configurations": projRows, + "total": result.Total, + }) }) projects.PATCH("/:uuid/configs/reorder", func(c *gin.Context) { diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index e35de47..0b47503 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -1235,6 +1235,67 @@ func (l *LocalDB) GetLastComponentSyncError() string { } +const openConfigUUIDsKey = "open_config_uuids" + +// GetOpenConfigUUIDs returns UUIDs of all configurations currently open in the configurator. +func (l *LocalDB) GetOpenConfigUUIDs() []string { + value, ok := l.getAppSettingValue(openConfigUUIDsKey) + if !ok || value == "" { + return nil + } + var uuids []string + if err := json.Unmarshal([]byte(value), &uuids); err != nil { + return nil + } + return uuids +} + +// AddOpenConfigUUID records that a configuration is open in the configurator. +func (l *LocalDB) AddOpenConfigUUID(uuid string) error { + uuids := l.GetOpenConfigUUIDs() + for _, u := range uuids { + if u == uuid { + return nil + } + } + uuids = append(uuids, uuid) + raw, err := json.Marshal(uuids) + if err != nil { + return err + } + return l.db.Exec(` + INSERT INTO app_settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `, openConfigUUIDsKey, string(raw), time.Now().Format(time.RFC3339)).Error +} + +// RemoveOpenConfigUUID records that a configuration is no longer open in the configurator. +func (l *LocalDB) RemoveOpenConfigUUID(uuid string) error { + uuids := l.GetOpenConfigUUIDs() + filtered := uuids[:0] + for _, u := range uuids { + if u != uuid { + filtered = append(filtered, u) + } + } + var raw []byte + var err error + if len(filtered) == 0 { + raw = []byte("[]") + } else { + raw, err = json.Marshal(filtered) + if err != nil { + return err + } + } + return l.db.Exec(` + INSERT INTO app_settings (key, value, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at + `, openConfigUUIDsKey, string(raw), time.Now().Format(time.RFC3339)).Error +} + // CountLocalPricelists returns the number of local pricelists func (l *LocalDB) CountLocalPricelists() int64 { var count int64 diff --git a/internal/services/sync/readiness.go b/internal/services/sync/readiness.go index cd2c84d..1af9266 100644 --- a/internal/services/sync/readiness.go +++ b/internal/services/sync/readiness.go @@ -249,6 +249,13 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) pricelistItemsCount := s.localDB.CountAllPricelistItems() componentsCount := s.localDB.CountComponents() dbSizeBytes := s.localDB.DBFileSizeBytes() + openConfigUUIDs := s.localDB.GetOpenConfigUUIDs() + var openConfigUUIDsJSON *string + if len(openConfigUUIDs) > 0 { + raw, _ := json.Marshal(openConfigUUIDs) + s := string(raw) + openConfigUUIDsJSON = &s + } return mariaDB.Exec(` INSERT INTO qt_client_schema_state ( username, hostname, app_version, @@ -257,9 +264,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version, last_sync_error_code, last_sync_error_text, local_pricelist_count, pricelist_items_count, components_count, db_size_bytes, + open_config_uuids, last_checked_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE app_version = VALUES(app_version), last_sync_at = VALUES(last_sync_at), @@ -277,6 +285,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) pricelist_items_count = VALUES(pricelist_items_count), components_count = VALUES(components_count), db_size_bytes = VALUES(db_size_bytes), + open_config_uuids = VALUES(open_config_uuids), last_checked_at = VALUES(last_checked_at), updated_at = VALUES(updated_at) `, username, hostname, appmeta.Version(), @@ -285,6 +294,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) estimateVersion, warehouseVersion, competitorVersion, lastSyncErrorCode, lastSyncErrorText, localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes, + openConfigUUIDsJSON, checkedAt, checkedAt).Error } diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 53bd30b..f5ed695 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -630,6 +630,56 @@ func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository. } } +// ListActiveViewersByConfigUUIDs returns a map of configUUID → []username for users +// who currently have those configs open (based on the last two sync cycles). +func (s *Service) ListActiveViewersByConfigUUIDs(uuids []string) (map[string][]string, error) { + if len(uuids) == 0 { + return map[string][]string{}, nil + } + mariaDB, err := s.getDB() + if err != nil || mariaDB == nil { + return map[string][]string{}, nil + } + selfUsername := strings.ToLower(strings.TrimSpace(s.localDB.GetDBUser())) + + type row struct { + Username string `gorm:"column:username"` + OpenConfigJSON string `gorm:"column:open_config_uuids"` + } + var rows []row + if err := mariaDB.Raw(` + SELECT username, open_config_uuids + FROM qt_client_schema_state + WHERE open_config_uuids IS NOT NULL + AND open_config_uuids != '[]' + AND last_checked_at > NOW() - INTERVAL 10 MINUTE + `).Scan(&rows).Error; err != nil { + return map[string][]string{}, nil + } + + wantSet := make(map[string]struct{}, len(uuids)) + for _, u := range uuids { + wantSet[u] = struct{}{} + } + + result := make(map[string][]string) + for _, r := range rows { + if strings.ToLower(strings.TrimSpace(r.Username)) == selfUsername { + continue + } + var openUUIDs []string + if err := json.Unmarshal([]byte(r.OpenConfigJSON), &openUUIDs); err != nil { + continue + } + for _, ou := range openUUIDs { + if _, ok := wantSet[ou]; ok { + result[ou] = append(result[ou], r.Username) + } + } + } + return result, nil +} + // ListUserSyncStatuses returns users who have recorded a client schema state check. func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyncStatus, error) { mariaDB, err := s.getDB() diff --git a/web/templates/configs.html b/web/templates/configs.html index b9a7d6e..2b47641 100644 --- a/web/templates/configs.html +++ b/web/templates/configs.html @@ -242,6 +242,7 @@ function renderConfigs(configs) { html += '