From d9e1926924a9714f1eee3d11a816b9e18ce01b12 Mon Sep 17 00:00:00 2001 From: VENKAT S P <109540044+venkatsp17@users.noreply.github.com> Date: Tue, 19 Aug 2025 23:32:06 +0530 Subject: [PATCH 1/6] feat(models): add apps and app_servers models with many-to-many relation --- internal/models/apps.go | 25 +++++++++++++++++++++++++ internal/models/metadata.go | 4 ++++ internal/storage/store.go | 14 +++++++++++++- 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 internal/models/apps.go diff --git a/internal/models/apps.go b/internal/models/apps.go new file mode 100644 index 0000000..72eaec1 --- /dev/null +++ b/internal/models/apps.go @@ -0,0 +1,25 @@ +package models + +import "time" + +// --- apps --- +type App struct { + ID string `json:"id" gorm:"primaryKey;size:64;not null"` + Name string `json:"name" gorm:"size:255;not null;uniqueIndex"` + Identifier string `json:"identifier" gorm:"size:255;not null;uniqueIndex"` + Description string `json:"description" gorm:"type:text"` + CreatedAt time.Time + UpdatedAt time.Time + + Servers []Metadata `json:"servers" gorm:"many2many:app_servers"` +} + +type AppServer struct { + AppID string `json:"app_id" gorm:"size:64;not null;primaryKey;column:app_id"` + MetadataID string `json:"metadata_id" gorm:"size:64;not null;primaryKey;column:metadata_id"` + CreatedAt time.Time + + // Optional FKs (good for cascades) + App App `gorm:"foreignKey:AppID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` + Metadata Metadata `gorm:"foreignKey:MetadataID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` +} diff --git a/internal/models/metadata.go b/internal/models/metadata.go index 73c21d7..d218485 100644 --- a/internal/models/metadata.go +++ b/internal/models/metadata.go @@ -2,6 +2,7 @@ package models import "time" +// --- servers (metadata) --- type Metadata struct { ID string `json:"id" gorm:"primaryKey;Size:64;not null"` Hostname string `json:"hostname"` @@ -16,4 +17,7 @@ type Metadata struct { TimestampUTC string `json:"timestamp_utc" gorm:"index"` CreatedAt time.Time UpdatedAt time.Time + + // Apps []App `json:"apps" gorm:"many2many:app_servers;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` + Apps []App `json:"apps" gorm:"many2many:app_servers"` } diff --git a/internal/storage/store.go b/internal/storage/store.go index 6aaa9ee..8dc3b0e 100644 --- a/internal/storage/store.go +++ b/internal/storage/store.go @@ -21,7 +21,19 @@ func Init(dbUrl string) (*Store, error) { if err != nil { return nil, err } - if err := db.AutoMigrate(&models.Metadata{}); err != nil { + + if err := db.AutoMigrate( + &models.Metadata{}, + &models.App{}, + &models.AppServer{}, + ); err != nil { + return nil, err + } + + if err := db.SetupJoinTable(&models.App{}, "Servers", &models.AppServer{}); err != nil { + return nil, err + } + if err := db.SetupJoinTable(&models.Metadata{}, "Apps", &models.AppServer{}); err != nil { return nil, err } From d9a44cd8e521228aeec0f3d137314c5a50b5be01 Mon Sep 17 00:00:00 2001 From: VENKAT S P <109540044+venkatsp17@users.noreply.github.com> Date: Tue, 19 Aug 2025 23:33:46 +0530 Subject: [PATCH 2/6] feat(storage): implement CRUD and membership methods for apps --- internal/storage/app_store.go | 247 ++++++++++++++++++++++++++++++++++ 1 file changed, 247 insertions(+) create mode 100644 internal/storage/app_store.go diff --git a/internal/storage/app_store.go b/internal/storage/app_store.go new file mode 100644 index 0000000..8112e1a --- /dev/null +++ b/internal/storage/app_store.go @@ -0,0 +1,247 @@ +// storage/apps_store.go +package storage + +import ( + "context" + "errors" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + "replicator/internal/models" +) + +const defaultTimeout = 5 * time.Second + +func withTimeout(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(ctx, defaultTimeout) +} + +func (s *Store) CreateApp(ctx context.Context, in AppCreate) (*models.App, error) { + ctx, cancel := withTimeout(ctx) + defer cancel() + app := &models.App{ + ID: in.ID, + Name: in.Name, + Identifier: in.Identifier, + Description: in.Description, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + if err := s.DB.WithContext(ctx).Create(app).Error; err != nil { + return nil, err + } + return app, nil +} + +func (s *Store) FindApp(ctx context.Context, sel AppSelector) (*models.App, error) { + ctx, cancel := withTimeout(ctx) + defer cancel() + var app models.App + tx := s.DB.WithContext(ctx).Model(&models.App{}) + switch { + case sel.ID != nil && *sel.ID != "": + if err := tx.First(&app, "id = ?", *sel.ID).Error; err != nil { + return nil, err + } + case sel.Identifier != nil && *sel.Identifier != "": + if err := tx.First(&app, "identifier = ?", *sel.Identifier).Error; err != nil { + return nil, err + } + default: + return nil, errors.New("empty selector") + } + return &app, nil +} + +func (s *Store) ListApps(ctx context.Context, afterID string, limit int) ([]models.App, string, error) { + ctx, cancel := withTimeout(ctx) + defer cancel() + if limit <= 0 || limit > 500 { + limit = 50 + } + q := s.DB.WithContext(ctx).Model(&models.App{}) + if afterID != "" { + q = q.Where("id > ?", afterID) + } + var apps []models.App + if err := q.Order("id ASC").Limit(limit).Find(&apps).Error; err != nil { + return nil, "", err + } + var next string + if len(apps) == limit { + next = apps[len(apps)-1].ID + } + return apps, next, nil +} + +func (s *Store) ModifyAppServers(ctx context.Context, sel AppSelector, serverIDs []string, op MembershipOp) error { + app, err := s.FindApp(ctx, sel) + if err != nil { + return err + } + switch op { + case MembershipAdd: + return s.addAppServers(ctx, app.ID, serverIDs) + case MembershipRemove: + return s.removeAppServers(ctx, app.ID, serverIDs) + case MembershipReplace: + return s.replaceAppServersDelta(ctx, app.ID, serverIDs) + default: + return errors.New("invalid membership op") + } +} + +func (s *Store) ListAppServers(ctx context.Context, sel AppSelector, cur Cursor) ([]models.Metadata, int64, string, error) { + app, err := s.FindApp(ctx, sel) + if err != nil { + return nil, 0, "", err + } + ctx, cancel := withTimeout(ctx) + defer cancel() + if cur.Limit <= 0 || cur.Limit > 500 { + cur.Limit = 50 + } + var total int64 + if err := s.DB.WithContext(ctx).Model(&models.AppServer{}).Where("app_id = ?", app.ID).Count(&total).Error; err != nil { + return nil, 0, "", err + } + sub := s.DB.WithContext(ctx).Model(&models.AppServer{}).Select("metadata_id").Where("app_id = ?", app.ID) + q := s.DB.WithContext(ctx).Model(&models.Metadata{}).Where("id IN (?)", sub) + if cur.AfterID != "" { + q = q.Where("id > ?", cur.AfterID) + } + var servers []models.Metadata + if err := q.Order("id ASC").Limit(cur.Limit).Find(&servers).Error; err != nil { + return nil, 0, "", err + } + var next string + if len(servers) == cur.Limit { + next = servers[len(servers)-1].ID + } + return servers, total, next, nil +} + +func (s *Store) addAppServers(ctx context.Context, appID string, serverIDs []string) error { + if len(serverIDs) == 0 { + return nil + } + ctx, cancel := withTimeout(ctx) + defer cancel() + serverIDs = unique(serverIDs) + var existing []string + if err := s.DB.WithContext(ctx).Model(&models.Metadata{}).Where("id IN ?", serverIDs).Pluck("id", &existing).Error; err != nil { + return err + } + if len(existing) == 0 { + return nil + } + now := time.Now() + links := make([]models.AppServer, 0, len(existing)) + for _, sid := range existing { + links = append(links, models.AppServer{AppID: appID, MetadataID: sid, CreatedAt: now}) + } + tx := s.DB.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "app_id"}, {Name: "metadata_id"}}, + DoNothing: true, + }) + const batch = 500 + return tx.CreateInBatches(&links, batch).Error +} + +func (s *Store) removeAppServers(ctx context.Context, appID string, serverIDs []string) error { + if len(serverIDs) == 0 { + return nil + } + ctx, cancel := withTimeout(ctx) + defer cancel() + serverIDs = unique(serverIDs) + return s.DB.WithContext(ctx).Where("app_id = ? AND metadata_id IN ?", appID, serverIDs).Delete(&models.AppServer{}).Error +} + +func (s *Store) replaceAppServersDelta(ctx context.Context, appID string, desired []string) error { + ctx, cancel := withTimeout(ctx) + defer cancel() + desired = unique(desired) + var current []string + if err := s.DB.WithContext(ctx).Model(&models.AppServer{}).Where("app_id = ?", appID).Pluck("metadata_id", ¤t).Error; err != nil { + return err + } + var existing []string + if len(desired) > 0 { + if err := s.DB.WithContext(ctx).Model(&models.Metadata{}).Where("id IN ?", desired).Pluck("id", &existing).Error; err != nil { + return err + } + } + addSet := diff(desired, current) + if len(addSet) > 0 { + addSet = intersect(addSet, existing) + } + delSet := diff(current, desired) + return s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if len(delSet) > 0 { + if err := tx.Where("app_id = ? AND metadata_id IN ?", appID, delSet).Delete(&models.AppServer{}).Error; err != nil { + return err + } + } + if len(addSet) > 0 { + now := time.Now() + links := make([]models.AppServer, 0, len(addSet)) + for _, sid := range addSet { + links = append(links, models.AppServer{AppID: appID, MetadataID: sid, CreatedAt: now}) + } + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "app_id"}, {Name: "metadata_id"}}, + DoNothing: true, + }).Create(&links).Error; err != nil { + return err + } + } + return nil + }) +} + +func unique(in []string) []string { + m := make(map[string]struct{}, len(in)) + out := make([]string, 0, len(in)) + for _, v := range in { + if v == "" { + continue + } + if _, ok := m[v]; ok { + continue + } + m[v] = struct{}{} + out = append(out, v) + } + return out +} + +func diff(a, b []string) []string { + mb := make(map[string]struct{}, len(b)) + for _, v := range b { + mb[v] = struct{}{} + } + out := make([]string, 0, len(a)) + for _, v := range a { + if _, ok := mb[v]; !ok && v != "" { + out = append(out, v) + } + } + return out +} + +func intersect(a, b []string) []string { + mb := make(map[string]struct{}, len(b)) + for _, v := range b { + mb[v] = struct{}{} + } + out := make([]string, 0, len(a)) + for _, v := range a { + if _, ok := mb[v]; ok { + out = append(out, v) + } + } + return out +} From e13426769424f9fc79d61727a6f09a08887dcdc7 Mon Sep 17 00:00:00 2001 From: VENKAT S P <109540044+venkatsp17@users.noreply.github.com> Date: Tue, 19 Aug 2025 23:34:43 +0530 Subject: [PATCH 3/6] feat(api): add HTTP handlers for apps and membership management --- internal/api/handlers/apps.go | 399 ++++++++++++++++++++++++++++++++++ internal/storage/dto.go | 27 +++ 2 files changed, 426 insertions(+) create mode 100644 internal/api/handlers/apps.go create mode 100644 internal/storage/dto.go diff --git a/internal/api/handlers/apps.go b/internal/api/handlers/apps.go new file mode 100644 index 0000000..abe5bf4 --- /dev/null +++ b/internal/api/handlers/apps.go @@ -0,0 +1,399 @@ +// handlers/apps.go +package handlers + +import ( + "encoding/json" + "net/http" + "regexp" + "strconv" + "strings" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + mw "replicator/internal/api/middleware" + "replicator/internal/models" + "replicator/internal/storage" +) + +var slugRe = regexp.MustCompile(`[^a-z0-9]+`) + +func slugify(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = slugRe.ReplaceAllString(s, "-") + return strings.Trim(s, "-") +} + +type createAppReq struct { + Name string `json:"name"` + Identifier string `json:"identifier"` + Description string `json:"description"` +} + +type addServersReq struct { + ServerIDs []string `json:"metadata_ids"` +} + +// POST /api/apps +func CreateAppHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("CreateAppHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + var req createAppReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Error("CreateAppHandler: decode failed", "error", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + name := strings.TrimSpace(req.Name) + if name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + identifier := slugify(req.Identifier) + if identifier == "" { + http.Error(w, "identifier is required", http.StatusBadRequest) + return + } + + var cnt int64 + if err := store.DB.Model(&models.App{}).Where("name = ?", name).Count(&cnt).Error; err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + if cnt > 0 { + http.Error(w, "name already exists", http.StatusConflict) + return + } + if err := store.DB.Model(&models.App{}).Where("identifier = ?", identifier).Count(&cnt).Error; err != nil { + http.Error(w, "db error", http.StatusInternalServerError) + return + } + if cnt > 0 { + http.Error(w, "identifier already exists", http.StatusConflict) + return + } + + app, err := store.CreateApp(r.Context(), storage.AppCreate{ + ID: uuid.NewString(), + Name: name, + Identifier: identifier, + Description: req.Description, + }) + if err != nil { + http.Error(w, "create failed", http.StatusInternalServerError) + return + } + + resp := map[string]string{ + "id": app.ID, + "name": app.Name, + "identifier": app.Identifier, + "description": app.Description, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// GET /api/apps +func ListAppsHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("ListAppsHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + afterID := r.URL.Query().Get("after_id") + limit := 50 + if lq := r.URL.Query().Get("limit"); lq != "" { + if v, err := strconv.Atoi(lq); err == nil && v > 0 && v <= 500 { + limit = v + } + } + + items, next, err := store.ListApps(r.Context(), afterID, limit) + if err != nil { + http.Error(w, "list failed", http.StatusInternalServerError) + return + } + + type appItem struct { + ID string `json:"id"` + Name string `json:"name"` + Identifier string `json:"identifier"` + Description string `json:"description"` + } + out := struct { + NextCursor string `json:"next_cursor"` + Items []appItem `json:"items"` + }{NextCursor: next, Items: make([]appItem, 0, len(items))} + + for i := range items { + out.Items = append(out.Items, appItem{ + ID: items[i].ID, + Name: items[i].Name, + Identifier: items[i].Identifier, + Description: items[i].Description, + }) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} + +// GET /api/apps/{id} +func GetAppByIDHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("GetAppByIDHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + id := chi.URLParam(r, "id") + app, err := store.FindApp(r.Context(), storage.AppSelector{ID: &id}) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + resp := map[string]string{ + "id": app.ID, + "name": app.Name, + "identifier": app.Identifier, + "description": app.Description, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// GET /api/apps/by-identifier/{identifier} +func GetAppByIdentifierHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("GetAppByIdentifierHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + raw := chi.URLParam(r, "identifier") + idf := slugify(raw) + + app, err := store.FindApp(r.Context(), storage.AppSelector{Identifier: &idf}) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + resp := map[string]string{ + "id": app.ID, + "name": app.Name, + "identifier": app.Identifier, + "description": app.Description, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// POST /api/apps/{appID}/servers +func AddServersToAppHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("AddServersToAppHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + appID := chi.URLParam(r, "appID") + + var req addServersReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Error("AddServersToAppHandler: decode failed", "error", err.Error()) + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if len(req.ServerIDs) == 0 { + http.Error(w, "metadata_ids required", http.StatusBadRequest) + return + } + + if err := store.ModifyAppServers(r.Context(), + storage.AppSelector{ID: &appID}, + req.ServerIDs, + storage.MembershipAdd); err != nil { + http.Error(w, "update failed", http.StatusInternalServerError) + return + } + + resp := map[string]any{ + "status": "ok", + "count": len(req.ServerIDs), + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// DELETE /api/apps/{appID}/servers/{serverID} +func RemoveServerFromAppHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("RemoveServerFromAppHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + appID := chi.URLParam(r, "appID") + serverID := chi.URLParam(r, "serverID") + + if err := store.ModifyAppServers(r.Context(), + storage.AppSelector{ID: &appID}, + []string{serverID}, + storage.MembershipRemove); err != nil { + http.Error(w, "delete failed", http.StatusInternalServerError) + return + } + + resp := map[string]string{"status": "ok"} + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) +} + +// GET /api/apps/{appID}/servers +func ListServersForAppHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("ListServersForAppHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + appID := chi.URLParam(r, "appID") + afterID := r.URL.Query().Get("after_id") + limit := 50 + if lq := r.URL.Query().Get("limit"); lq != "" { + if v, err := strconv.Atoi(lq); err == nil && v > 0 && v <= 500 { + limit = v + } + } + + servers, total, next, err := store.ListAppServers( + r.Context(), + storage.AppSelector{ID: &appID}, + storage.Cursor{AfterID: afterID, Limit: limit}, + ) + if err != nil { + http.Error(w, "list failed", http.StatusInternalServerError) + return + } + + type serverItem struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + OS string `json:"os"` + Arch string `json:"arch"` + NumCPU int `json:"num_cpu"` + TimestampUTC string `json:"timestamp_utc"` + } + + out := struct { + Total int64 `json:"total"` + NextCursor string `json:"next_cursor"` + Items []serverItem `json:"items"` + }{Total: total, NextCursor: next, Items: make([]serverItem, 0, len(servers))} + + for i := range servers { + out.Items = append(out.Items, serverItem{ + ID: servers[i].ID, + Hostname: servers[i].Hostname, + OS: servers[i].OS, + Arch: servers[i].Arch, + NumCPU: servers[i].NumCPU, + TimestampUTC: servers[i].TimestampUTC, + }) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} + +// GET /api/servers?app= (alias) +func ListServersByAppIdentifierAliasHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + log.Error("ListServersByAppIdentifierAliasHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + idf := slugify(r.URL.Query().Get("app")) + if idf == "" { + http.Error(w, "app is required", http.StatusBadRequest) + return + } + + afterID := r.URL.Query().Get("after_id") + limit := 50 + if lq := r.URL.Query().Get("limit"); lq != "" { + if v, err := strconv.Atoi(lq); err == nil && v > 0 && v <= 500 { + limit = v + } + } + + app, err := store.FindApp(r.Context(), storage.AppSelector{Identifier: &idf}) + if err != nil { + http.Error(w, "not found", http.StatusNotFound) + return + } + + servers, total, next, err := store.ListAppServers( + r.Context(), + storage.AppSelector{ID: &app.ID}, + storage.Cursor{AfterID: afterID, Limit: limit}, + ) + if err != nil { + http.Error(w, "list failed", http.StatusInternalServerError) + return + } + + type serverItem struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + OS string `json:"os"` + Arch string `json:"arch"` + NumCPU int `json:"num_cpu"` + TimestampUTC string `json:"timestamp_utc"` + } + + out := struct { + Total int64 `json:"total"` + NextCursor string `json:"next_cursor"` + Items []serverItem `json:"items"` + }{Total: total, NextCursor: next, Items: make([]serverItem, 0, len(servers))} + + for i := range servers { + out.Items = append(out.Items, serverItem{ + ID: servers[i].ID, + Hostname: servers[i].Hostname, + OS: servers[i].OS, + Arch: servers[i].Arch, + NumCPU: servers[i].NumCPU, + TimestampUTC: servers[i].TimestampUTC, + }) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(out) +} diff --git a/internal/storage/dto.go b/internal/storage/dto.go new file mode 100644 index 0000000..594eaa2 --- /dev/null +++ b/internal/storage/dto.go @@ -0,0 +1,27 @@ +// storage/dto.go +package storage + +type AppCreate struct { + ID string + Name string + Identifier string + Description string +} + +type AppSelector struct { + ID *string + Identifier *string +} + +type Cursor struct { + AfterID string + Limit int +} + +type MembershipOp string + +const ( + MembershipAdd MembershipOp = "add" + MembershipRemove MembershipOp = "remove" + MembershipReplace MembershipOp = "replace" +) From 421bf54eca27f18c2db7ab385011e26d04435e7c Mon Sep 17 00:00:00 2001 From: VENKAT S P <109540044+venkatsp17@users.noreply.github.com> Date: Tue, 19 Aug 2025 23:35:07 +0530 Subject: [PATCH 4/6] chore(api): add debug endpoints and seed sample data for testing --- internal/api/handlers/debug.go | 28 ++++++++++++ internal/storage/seed.go | 84 ++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 internal/api/handlers/debug.go create mode 100644 internal/storage/seed.go diff --git a/internal/api/handlers/debug.go b/internal/api/handlers/debug.go new file mode 100644 index 0000000..24ce16f --- /dev/null +++ b/internal/api/handlers/debug.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + mw "replicator/internal/api/middleware" +) + +func SeedHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil { + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + if err := store.SeedSampleData(r.Context()); err != nil { + log.Error("SeedHandler failed", "error", err.Error()) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + _ = json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "msg": "Sample data inserted", + }) +} diff --git a/internal/storage/seed.go b/internal/storage/seed.go new file mode 100644 index 0000000..ff6e59a --- /dev/null +++ b/internal/storage/seed.go @@ -0,0 +1,84 @@ +package storage + +import ( + "context" + "time" + + "github.com/google/uuid" + "replicator/internal/models" +) + +// SeedSampleData inserts sample apps and servers for testing. +func (s *Store) SeedSampleData(ctx context.Context) error { + now := time.Now() + + // Sample servers + servers := []models.Metadata{ + { + ID: uuid.NewString(), + Hostname: "srv-payments-01", + OS: "linux", + Arch: "amd64", + NumCPU: 4, + Kernel: "5.15.0", + Uptime: "12h", + TotalMemoryMB: 8192, + TotalDiskSizeGB: "100", + MountedCount: 3, + TimestampUTC: now.UTC().Format(time.RFC3339), + }, + { + ID: uuid.NewString(), + Hostname: "srv-analytics-01", + OS: "linux", + Arch: "amd64", + NumCPU: 8, + Kernel: "5.15.0", + Uptime: "3h", + TotalMemoryMB: 16384, + TotalDiskSizeGB: "200", + MountedCount: 4, + TimestampUTC: now.UTC().Format(time.RFC3339), + }, + } + + // Insert servers + if err := s.DB.Create(&servers).Error; err != nil { + return err + } + + // Sample apps + apps := []models.App{ + { + ID: uuid.NewString(), + Name: "Payments", + Identifier: "payments", + Description: "Prod payment service", + CreatedAt: now, + UpdatedAt: now, + }, + { + ID: uuid.NewString(), + Name: "Analytics", + Identifier: "analytics", + Description: "Analytics and BI service", + CreatedAt: now, + UpdatedAt: now, + }, + } + + if err := s.DB.Create(&apps).Error; err != nil { + return err + } + + // Link first server to Payments app + appServer := models.AppServer{ + AppID: apps[0].ID, + MetadataID: servers[0].ID, + } + if err := s.DB.Create(&appServer).Error; err != nil { + return err + } + + return nil +} From 8ba7cb168f6ce98cea8c98ed4ebd0cdb12bbab54 Mon Sep 17 00:00:00 2001 From: VENKAT S P <109540044+venkatsp17@users.noreply.github.com> Date: Thu, 21 Aug 2025 00:00:16 +0530 Subject: [PATCH 5/6] refac(all): Updated code based on review comments --- internal/api/dto/response.go | 42 +++++++ internal/api/handlers/apps.go | 216 ++++++++-------------------------- internal/api/router.go | 14 +++ internal/models/apps.go | 1 - internal/storage/app_store.go | 139 ++++++++-------------- internal/storage/dto.go | 4 +- internal/storage/seed.go | 2 - 7 files changed, 155 insertions(+), 263 deletions(-) create mode 100644 internal/api/dto/response.go diff --git a/internal/api/dto/response.go b/internal/api/dto/response.go new file mode 100644 index 0000000..8c40427 --- /dev/null +++ b/internal/api/dto/response.go @@ -0,0 +1,42 @@ +package dto + +// App is the response shape for a single app. +type App struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` +} + +// AppList is the response shape for list apps. +type AppList struct { + NextCursor string `json:"next_cursor"` + Items []App `json:"items"` +} + +// Server is the response shape for a server within an app listing. +type Server struct { + ID string `json:"id"` + Hostname string `json:"hostname"` + OS string `json:"os"` + Arch string `json:"arch"` + NumCPU int `json:"num_cpu"` + TimestampUTC string `json:"timestamp_utc"` +} + +// ServerList is the response shape for listing servers in an app. +type ServerList struct { + Total int64 `json:"total"` + NextCursor string `json:"next_cursor"` + Items []Server `json:"items"` +} + +// Status is a generic OK/ERR style response. +type Status struct { + Status string `json:"status"` +} + +// StatusCount is used where we also want to return a count. +type StatusCount struct { + Status string `json:"status"` + Count int `json:"count"` +} diff --git a/internal/api/handlers/apps.go b/internal/api/handlers/apps.go index abe5bf4..b15e9f6 100644 --- a/internal/api/handlers/apps.go +++ b/internal/api/handlers/apps.go @@ -1,4 +1,3 @@ -// handlers/apps.go package handlers import ( @@ -10,6 +9,8 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" + + "replicator/internal/api/dto" mw "replicator/internal/api/middleware" "replicator/internal/models" "replicator/internal/storage" @@ -25,7 +26,6 @@ func slugify(s string) string { type createAppReq struct { Name string `json:"name"` - Identifier string `json:"identifier"` Description string `json:"description"` } @@ -55,11 +55,6 @@ func CreateAppHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "name is required", http.StatusBadRequest) return } - identifier := slugify(req.Identifier) - if identifier == "" { - http.Error(w, "identifier is required", http.StatusBadRequest) - return - } var cnt int64 if err := store.DB.Model(&models.App{}).Where("name = ?", name).Count(&cnt).Error; err != nil { @@ -70,19 +65,10 @@ func CreateAppHandler(w http.ResponseWriter, r *http.Request) { http.Error(w, "name already exists", http.StatusConflict) return } - if err := store.DB.Model(&models.App{}).Where("identifier = ?", identifier).Count(&cnt).Error; err != nil { - http.Error(w, "db error", http.StatusInternalServerError) - return - } - if cnt > 0 { - http.Error(w, "identifier already exists", http.StatusConflict) - return - } - app, err := store.CreateApp(r.Context(), storage.AppCreate{ + app, err := store.CreateApp(storage.AppCreate{ ID: uuid.NewString(), Name: name, - Identifier: identifier, Description: req.Description, }) if err != nil { @@ -90,16 +76,41 @@ func CreateAppHandler(w http.ResponseWriter, r *http.Request) { return } - resp := map[string]string{ - "id": app.ID, - "name": app.Name, - "identifier": app.Identifier, - "description": app.Description, + resp := dto.App{ + ID: app.ID, + Name: app.Name, + Description: app.Description, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } +// DELETE /api/apps/{id} +func DeleteAppHandler(w http.ResponseWriter, r *http.Request) { + log := mw.GetLogFromCtx(r) + store := mw.StoreFrom(r) + if store == nil || store.DB == nil { + log.Error("DeleteAppHandler: store missing") + http.Error(w, "store missing", http.StatusInternalServerError) + return + } + + id := chi.URLParam(r, "id") + if strings.TrimSpace(id) == "" { + http.Error(w, "id required", http.StatusBadRequest) + return + } + + if err := store.DeleteApp(storage.AppSelector{ID: &id}); err != nil { + log.Error("DeleteAppHandler: db error", "error", err.Error()) + http.Error(w, "delete failed", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(dto.Status{Status: "ok"}) +} + // GET /api/apps func ListAppsHandler(w http.ResponseWriter, r *http.Request) { log := mw.GetLogFromCtx(r) @@ -118,28 +129,20 @@ func ListAppsHandler(w http.ResponseWriter, r *http.Request) { } } - items, next, err := store.ListApps(r.Context(), afterID, limit) + items, next, err := store.ListApps(afterID, limit) if err != nil { http.Error(w, "list failed", http.StatusInternalServerError) return } - type appItem struct { - ID string `json:"id"` - Name string `json:"name"` - Identifier string `json:"identifier"` - Description string `json:"description"` + out := dto.AppList{ + NextCursor: next, + Items: make([]dto.App, 0, len(items)), } - out := struct { - NextCursor string `json:"next_cursor"` - Items []appItem `json:"items"` - }{NextCursor: next, Items: make([]appItem, 0, len(items))} - for i := range items { - out.Items = append(out.Items, appItem{ + out.Items = append(out.Items, dto.App{ ID: items[i].ID, Name: items[i].Name, - Identifier: items[i].Identifier, Description: items[i].Description, }) } @@ -159,46 +162,16 @@ func GetAppByIDHandler(w http.ResponseWriter, r *http.Request) { } id := chi.URLParam(r, "id") - app, err := store.FindApp(r.Context(), storage.AppSelector{ID: &id}) - if err != nil { - http.Error(w, "not found", http.StatusNotFound) - return - } - - resp := map[string]string{ - "id": app.ID, - "name": app.Name, - "identifier": app.Identifier, - "description": app.Description, - } - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) -} - -// GET /api/apps/by-identifier/{identifier} -func GetAppByIdentifierHandler(w http.ResponseWriter, r *http.Request) { - log := mw.GetLogFromCtx(r) - store := mw.StoreFrom(r) - if store == nil { - log.Error("GetAppByIdentifierHandler: store missing") - http.Error(w, "store missing", http.StatusInternalServerError) - return - } - - raw := chi.URLParam(r, "identifier") - idf := slugify(raw) - - app, err := store.FindApp(r.Context(), storage.AppSelector{Identifier: &idf}) + app, err := store.FindApp(storage.AppSelector{ID: &id}) if err != nil { http.Error(w, "not found", http.StatusNotFound) return } - resp := map[string]string{ - "id": app.ID, - "name": app.Name, - "identifier": app.Identifier, - "description": app.Description, + resp := dto.App{ + ID: app.ID, + Name: app.Name, + Description: app.Description, } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) @@ -227,7 +200,7 @@ func AddServersToAppHandler(w http.ResponseWriter, r *http.Request) { return } - if err := store.ModifyAppServers(r.Context(), + if err := store.ModifyAppServers( storage.AppSelector{ID: &appID}, req.ServerIDs, storage.MembershipAdd); err != nil { @@ -235,10 +208,7 @@ func AddServersToAppHandler(w http.ResponseWriter, r *http.Request) { return } - resp := map[string]any{ - "status": "ok", - "count": len(req.ServerIDs), - } + resp := dto.StatusCount{Status: "ok", Count: len(req.ServerIDs)} w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } @@ -256,7 +226,7 @@ func RemoveServerFromAppHandler(w http.ResponseWriter, r *http.Request) { appID := chi.URLParam(r, "appID") serverID := chi.URLParam(r, "serverID") - if err := store.ModifyAppServers(r.Context(), + if err := store.ModifyAppServers( storage.AppSelector{ID: &appID}, []string{serverID}, storage.MembershipRemove); err != nil { @@ -264,9 +234,8 @@ func RemoveServerFromAppHandler(w http.ResponseWriter, r *http.Request) { return } - resp := map[string]string{"status": "ok"} w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(resp) + _ = json.NewEncoder(w).Encode(dto.Status{Status: "ok"}) } // GET /api/apps/{appID}/servers @@ -289,7 +258,6 @@ func ListServersForAppHandler(w http.ResponseWriter, r *http.Request) { } servers, total, next, err := store.ListAppServers( - r.Context(), storage.AppSelector{ID: &appID}, storage.Cursor{AfterID: afterID, Limit: limit}, ) @@ -298,93 +266,13 @@ func ListServersForAppHandler(w http.ResponseWriter, r *http.Request) { return } - type serverItem struct { - ID string `json:"id"` - Hostname string `json:"hostname"` - OS string `json:"os"` - Arch string `json:"arch"` - NumCPU int `json:"num_cpu"` - TimestampUTC string `json:"timestamp_utc"` + out := dto.ServerList{ + Total: total, + NextCursor: next, + Items: make([]dto.Server, 0, len(servers)), } - - out := struct { - Total int64 `json:"total"` - NextCursor string `json:"next_cursor"` - Items []serverItem `json:"items"` - }{Total: total, NextCursor: next, Items: make([]serverItem, 0, len(servers))} - - for i := range servers { - out.Items = append(out.Items, serverItem{ - ID: servers[i].ID, - Hostname: servers[i].Hostname, - OS: servers[i].OS, - Arch: servers[i].Arch, - NumCPU: servers[i].NumCPU, - TimestampUTC: servers[i].TimestampUTC, - }) - } - - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(out) -} - -// GET /api/servers?app= (alias) -func ListServersByAppIdentifierAliasHandler(w http.ResponseWriter, r *http.Request) { - log := mw.GetLogFromCtx(r) - store := mw.StoreFrom(r) - if store == nil { - log.Error("ListServersByAppIdentifierAliasHandler: store missing") - http.Error(w, "store missing", http.StatusInternalServerError) - return - } - - idf := slugify(r.URL.Query().Get("app")) - if idf == "" { - http.Error(w, "app is required", http.StatusBadRequest) - return - } - - afterID := r.URL.Query().Get("after_id") - limit := 50 - if lq := r.URL.Query().Get("limit"); lq != "" { - if v, err := strconv.Atoi(lq); err == nil && v > 0 && v <= 500 { - limit = v - } - } - - app, err := store.FindApp(r.Context(), storage.AppSelector{Identifier: &idf}) - if err != nil { - http.Error(w, "not found", http.StatusNotFound) - return - } - - servers, total, next, err := store.ListAppServers( - r.Context(), - storage.AppSelector{ID: &app.ID}, - storage.Cursor{AfterID: afterID, Limit: limit}, - ) - if err != nil { - http.Error(w, "list failed", http.StatusInternalServerError) - return - } - - type serverItem struct { - ID string `json:"id"` - Hostname string `json:"hostname"` - OS string `json:"os"` - Arch string `json:"arch"` - NumCPU int `json:"num_cpu"` - TimestampUTC string `json:"timestamp_utc"` - } - - out := struct { - Total int64 `json:"total"` - NextCursor string `json:"next_cursor"` - Items []serverItem `json:"items"` - }{Total: total, NextCursor: next, Items: make([]serverItem, 0, len(servers))} - for i := range servers { - out.Items = append(out.Items, serverItem{ + out.Items = append(out.Items, dto.Server{ ID: servers[i].ID, Hostname: servers[i].Hostname, OS: servers[i].OS, diff --git a/internal/api/router.go b/internal/api/router.go index 9754287..b1e13b6 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -26,6 +26,20 @@ func NewRouter(store *storage.Store, logger *slog.Logger) http.Handler { r.Post("/discover", handlers.DiscoverHandler) r.Get("/servers", handlers.ListServersHandler) r.Get("/servers/{id}", handlers.GetServerHandler) + + r.Route("/apps", func(r chi.Router) { + r.Post("/", handlers.CreateAppHandler) + r.Get("/", handlers.ListAppsHandler) + r.Get("/{id}", handlers.GetAppByIDHandler) + r.Post("/{appID}/servers", handlers.AddServersToAppHandler) + r.Delete("/{appID}/servers/{serverID}", handlers.RemoveServerFromAppHandler) + r.Get("/{appID}/servers", handlers.ListServersForAppHandler) + r.Delete("/{id}", handlers.DeleteAppHandler) + }) + + // debug seed route — IMPORTANT: stays inside this block + r.Post("/debug/seed", handlers.SeedHandler) + }) // UI routes diff --git a/internal/models/apps.go b/internal/models/apps.go index 72eaec1..e208f5a 100644 --- a/internal/models/apps.go +++ b/internal/models/apps.go @@ -6,7 +6,6 @@ import "time" type App struct { ID string `json:"id" gorm:"primaryKey;size:64;not null"` Name string `json:"name" gorm:"size:255;not null;uniqueIndex"` - Identifier string `json:"identifier" gorm:"size:255;not null;uniqueIndex"` Description string `json:"description" gorm:"type:text"` CreatedAt time.Time UpdatedAt time.Time diff --git a/internal/storage/app_store.go b/internal/storage/app_store.go index 8112e1a..edb9b2b 100644 --- a/internal/storage/app_store.go +++ b/internal/storage/app_store.go @@ -2,66 +2,71 @@ package storage import ( - "context" "errors" - "time" - "gorm.io/gorm" "gorm.io/gorm/clause" + "time" "replicator/internal/models" ) -const defaultTimeout = 5 * time.Second - -func withTimeout(ctx context.Context) (context.Context, context.CancelFunc) { - return context.WithTimeout(ctx, defaultTimeout) -} - -func (s *Store) CreateApp(ctx context.Context, in AppCreate) (*models.App, error) { - ctx, cancel := withTimeout(ctx) - defer cancel() +func (s *Store) CreateApp(in AppCreate) (*models.App, error) { app := &models.App{ ID: in.ID, Name: in.Name, - Identifier: in.Identifier, Description: in.Description, CreatedAt: time.Now(), UpdatedAt: time.Now(), } - if err := s.DB.WithContext(ctx).Create(app).Error; err != nil { + if err := s.DB.Create(app).Error; err != nil { return nil, err } return app, nil } -func (s *Store) FindApp(ctx context.Context, sel AppSelector) (*models.App, error) { - ctx, cancel := withTimeout(ctx) - defer cancel() +// DeleteApp deletes the app row and detaches all related servers (rows in app_servers). +func (s *Store) DeleteApp(sel AppSelector) error { + app, err := s.FindApp(sel) + if err != nil { + return err + } + + return s.DB.Transaction(func(tx *gorm.DB) error { + // detach memberships + if err := tx.Where("app_id = ?", app.ID).Delete(&models.AppServer{}).Error; err != nil { + return err + } + // delete the app itself + res := tx.Delete(&models.App{}, "id = ?", app.ID) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil + }) +} + +func (s *Store) FindApp(sel AppSelector) (*models.App, error) { var app models.App - tx := s.DB.WithContext(ctx).Model(&models.App{}) + tx := s.DB.Model(&models.App{}) switch { case sel.ID != nil && *sel.ID != "": if err := tx.First(&app, "id = ?", *sel.ID).Error; err != nil { return nil, err } - case sel.Identifier != nil && *sel.Identifier != "": - if err := tx.First(&app, "identifier = ?", *sel.Identifier).Error; err != nil { - return nil, err - } default: return nil, errors.New("empty selector") } return &app, nil } -func (s *Store) ListApps(ctx context.Context, afterID string, limit int) ([]models.App, string, error) { - ctx, cancel := withTimeout(ctx) - defer cancel() +func (s *Store) ListApps(afterID string, limit int) ([]models.App, string, error) { if limit <= 0 || limit > 500 { limit = 50 } - q := s.DB.WithContext(ctx).Model(&models.App{}) + q := s.DB.Model(&models.App{}) if afterID != "" { q = q.Where("id > ?", afterID) } @@ -76,39 +81,35 @@ func (s *Store) ListApps(ctx context.Context, afterID string, limit int) ([]mode return apps, next, nil } -func (s *Store) ModifyAppServers(ctx context.Context, sel AppSelector, serverIDs []string, op MembershipOp) error { - app, err := s.FindApp(ctx, sel) +func (s *Store) ModifyAppServers(sel AppSelector, serverIDs []string, op MembershipOp) error { + app, err := s.FindApp(sel) if err != nil { return err } switch op { case MembershipAdd: - return s.addAppServers(ctx, app.ID, serverIDs) + return s.addAppServers(app.ID, serverIDs) case MembershipRemove: - return s.removeAppServers(ctx, app.ID, serverIDs) - case MembershipReplace: - return s.replaceAppServersDelta(ctx, app.ID, serverIDs) + return s.removeAppServers(app.ID, serverIDs) default: return errors.New("invalid membership op") } } -func (s *Store) ListAppServers(ctx context.Context, sel AppSelector, cur Cursor) ([]models.Metadata, int64, string, error) { - app, err := s.FindApp(ctx, sel) +func (s *Store) ListAppServers(sel AppSelector, cur Cursor) ([]models.Metadata, int64, string, error) { + app, err := s.FindApp(sel) if err != nil { return nil, 0, "", err } - ctx, cancel := withTimeout(ctx) - defer cancel() if cur.Limit <= 0 || cur.Limit > 500 { cur.Limit = 50 } var total int64 - if err := s.DB.WithContext(ctx).Model(&models.AppServer{}).Where("app_id = ?", app.ID).Count(&total).Error; err != nil { + if err := s.DB.Model(&models.AppServer{}).Where("app_id = ?", app.ID).Count(&total).Error; err != nil { return nil, 0, "", err } - sub := s.DB.WithContext(ctx).Model(&models.AppServer{}).Select("metadata_id").Where("app_id = ?", app.ID) - q := s.DB.WithContext(ctx).Model(&models.Metadata{}).Where("id IN (?)", sub) + sub := s.DB.Model(&models.AppServer{}).Select("metadata_id").Where("app_id = ?", app.ID) + q := s.DB.Model(&models.Metadata{}).Where("id IN (?)", sub) if cur.AfterID != "" { q = q.Where("id > ?", cur.AfterID) } @@ -123,15 +124,13 @@ func (s *Store) ListAppServers(ctx context.Context, sel AppSelector, cur Cursor) return servers, total, next, nil } -func (s *Store) addAppServers(ctx context.Context, appID string, serverIDs []string) error { +func (s *Store) addAppServers(appID string, serverIDs []string) error { if len(serverIDs) == 0 { return nil } - ctx, cancel := withTimeout(ctx) - defer cancel() serverIDs = unique(serverIDs) var existing []string - if err := s.DB.WithContext(ctx).Model(&models.Metadata{}).Where("id IN ?", serverIDs).Pluck("id", &existing).Error; err != nil { + if err := s.DB.Model(&models.Metadata{}).Where("id IN ?", serverIDs).Pluck("id", &existing).Error; err != nil { return err } if len(existing) == 0 { @@ -142,64 +141,18 @@ func (s *Store) addAppServers(ctx context.Context, appID string, serverIDs []str for _, sid := range existing { links = append(links, models.AppServer{AppID: appID, MetadataID: sid, CreatedAt: now}) } - tx := s.DB.WithContext(ctx).Clauses(clause.OnConflict{ + return s.DB.Clauses(clause.OnConflict{ Columns: []clause.Column{{Name: "app_id"}, {Name: "metadata_id"}}, DoNothing: true, - }) - const batch = 500 - return tx.CreateInBatches(&links, batch).Error + }).CreateInBatches(&links, 500).Error } -func (s *Store) removeAppServers(ctx context.Context, appID string, serverIDs []string) error { +func (s *Store) removeAppServers(appID string, serverIDs []string) error { if len(serverIDs) == 0 { return nil } - ctx, cancel := withTimeout(ctx) - defer cancel() serverIDs = unique(serverIDs) - return s.DB.WithContext(ctx).Where("app_id = ? AND metadata_id IN ?", appID, serverIDs).Delete(&models.AppServer{}).Error -} - -func (s *Store) replaceAppServersDelta(ctx context.Context, appID string, desired []string) error { - ctx, cancel := withTimeout(ctx) - defer cancel() - desired = unique(desired) - var current []string - if err := s.DB.WithContext(ctx).Model(&models.AppServer{}).Where("app_id = ?", appID).Pluck("metadata_id", ¤t).Error; err != nil { - return err - } - var existing []string - if len(desired) > 0 { - if err := s.DB.WithContext(ctx).Model(&models.Metadata{}).Where("id IN ?", desired).Pluck("id", &existing).Error; err != nil { - return err - } - } - addSet := diff(desired, current) - if len(addSet) > 0 { - addSet = intersect(addSet, existing) - } - delSet := diff(current, desired) - return s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if len(delSet) > 0 { - if err := tx.Where("app_id = ? AND metadata_id IN ?", appID, delSet).Delete(&models.AppServer{}).Error; err != nil { - return err - } - } - if len(addSet) > 0 { - now := time.Now() - links := make([]models.AppServer, 0, len(addSet)) - for _, sid := range addSet { - links = append(links, models.AppServer{AppID: appID, MetadataID: sid, CreatedAt: now}) - } - if err := tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "app_id"}, {Name: "metadata_id"}}, - DoNothing: true, - }).Create(&links).Error; err != nil { - return err - } - } - return nil - }) + return s.DB.Where("app_id = ? AND metadata_id IN ?", appID, serverIDs).Delete(&models.AppServer{}).Error } func unique(in []string) []string { diff --git a/internal/storage/dto.go b/internal/storage/dto.go index 594eaa2..424084a 100644 --- a/internal/storage/dto.go +++ b/internal/storage/dto.go @@ -4,13 +4,11 @@ package storage type AppCreate struct { ID string Name string - Identifier string Description string } type AppSelector struct { - ID *string - Identifier *string + ID *string } type Cursor struct { diff --git a/internal/storage/seed.go b/internal/storage/seed.go index ff6e59a..fe93205 100644 --- a/internal/storage/seed.go +++ b/internal/storage/seed.go @@ -52,7 +52,6 @@ func (s *Store) SeedSampleData(ctx context.Context) error { { ID: uuid.NewString(), Name: "Payments", - Identifier: "payments", Description: "Prod payment service", CreatedAt: now, UpdatedAt: now, @@ -60,7 +59,6 @@ func (s *Store) SeedSampleData(ctx context.Context) error { { ID: uuid.NewString(), Name: "Analytics", - Identifier: "analytics", Description: "Analytics and BI service", CreatedAt: now, UpdatedAt: now, From b8ef734998811e9432228c41dbb7d56fd95f2698 Mon Sep 17 00:00:00 2001 From: VENKAT S P <109540044+venkatsp17@users.noreply.github.com> Date: Thu, 21 Aug 2025 19:03:37 +0530 Subject: [PATCH 6/6] fix(all): Remove unused func and vars --- internal/api/handlers/apps.go | 9 --------- internal/storage/app_store.go | 28 ---------------------------- 2 files changed, 37 deletions(-) diff --git a/internal/api/handlers/apps.go b/internal/api/handlers/apps.go index b15e9f6..6fe32e2 100644 --- a/internal/api/handlers/apps.go +++ b/internal/api/handlers/apps.go @@ -3,7 +3,6 @@ package handlers import ( "encoding/json" "net/http" - "regexp" "strconv" "strings" @@ -16,14 +15,6 @@ import ( "replicator/internal/storage" ) -var slugRe = regexp.MustCompile(`[^a-z0-9]+`) - -func slugify(s string) string { - s = strings.ToLower(strings.TrimSpace(s)) - s = slugRe.ReplaceAllString(s, "-") - return strings.Trim(s, "-") -} - type createAppReq struct { Name string `json:"name"` Description string `json:"description"` diff --git a/internal/storage/app_store.go b/internal/storage/app_store.go index edb9b2b..1f6e7af 100644 --- a/internal/storage/app_store.go +++ b/internal/storage/app_store.go @@ -170,31 +170,3 @@ func unique(in []string) []string { } return out } - -func diff(a, b []string) []string { - mb := make(map[string]struct{}, len(b)) - for _, v := range b { - mb[v] = struct{}{} - } - out := make([]string, 0, len(a)) - for _, v := range a { - if _, ok := mb[v]; !ok && v != "" { - out = append(out, v) - } - } - return out -} - -func intersect(a, b []string) []string { - mb := make(map[string]struct{}, len(b)) - for _, v := range b { - mb[v] = struct{}{} - } - out := make([]string, 0, len(a)) - for _, v := range a { - if _, ok := mb[v]; ok { - out = append(out, v) - } - } - return out -}