From 95f0fc0b7ad3d9214400ed9e7d36c5d48a313aee Mon Sep 17 00:00:00 2001 From: Bardioc26 <13843924+Bardioc26@users.noreply.github.com> Date: Tue, 3 Feb 2026 17:21:43 +0100 Subject: [PATCH] editing and maintenance and user suggestions * every user has a right of a username and a display name and has the right to change it * System information page now shows information about database user count, char count, and database schema version * more maintenance lists * show the right values in columns and fields * move similar from inside of frontend component functions to utility js when used multiple times * display help on mouse over * add more than one believe to character * make char name editable with better char info in headline * GiT Gifttoleranz value not calculated correctly * Bump backend to 0.2.3, frontend to 0.2.2 --- backend/{config => appsystem}/handlers.go | 6 +- backend/{config => appsystem}/routes.go | 4 +- backend/{config => appsystem}/version.go | 39 +- backend/{config => appsystem}/version_test.go | 26 +- backend/character/handlers.go | 1 + backend/character/handlers_test.go | 4 +- backend/character/share_handlers.go | 26 +- backend/character/share_handlers_test.go | 93 +++++ backend/cmd/main.go | 5 +- backend/gsmaster/misclookup.go | 97 +++++ backend/gsmaster/misclookup_test.go | 17 +- backend/maintenance/believe_handlers.go | 155 ++++++++ backend/maintenance/believe_handlers_test.go | 160 ++++++++ backend/maintenance/masterdata_handlers.go | 356 ++++++++++++++++++ .../maintenance/masterdata_handlers_test.go | 292 ++++++++++++++ backend/maintenance/routes.go | 10 + backend/models/model_character.go | 12 +- backend/models/model_character_test.go | 39 +- backend/transfer/database.go | 4 +- backend/user/admin_handlers.go | 9 +- backend/user/handlers.go | 57 ++- backend/user/handlers_test.go | 117 ++++++ backend/user/model.go | 17 + backend/user/role_test.go | 38 +- backend/user/routes.go | 1 + frontend/VERSION.md | 2 +- frontend/package.json | 2 +- .../CharacterCreation/CharacterBasicInfo.vue | 44 ++- frontend/src/components/CharacterDetails.vue | 2 +- frontend/src/components/DatasheetView.vue | 76 +++- frontend/src/components/Maintenance.vue | 23 +- frontend/src/components/ResetPasswordForm.vue | 5 +- frontend/src/components/VisibilityDialog.vue | 5 +- .../components/maintenance/BelieveView.vue | 300 +++++++++++++++ .../components/maintenance/EquipmentView.vue | 53 ++- .../components/maintenance/GameSystemView.vue | 174 +++++++++ .../components/maintenance/LitSourceView.vue | 277 ++++++++++++++ .../components/maintenance/MiscLookupView.vue | 284 ++++++++++++++ .../maintenance/SkillImprovementCostView.vue | 203 ++++++++++ .../src/components/maintenance/SkillView.vue | 81 +++- .../src/components/maintenance/SpellView.vue | 53 ++- .../maintenance/WeaponSkillView.vue | 53 ++- .../src/components/maintenance/WeaponView.vue | 53 ++- frontend/src/locales/de | 91 ++++- frontend/src/locales/en | 91 ++++- frontend/src/stores/userStore.js | 10 +- frontend/src/utils/maintenanceGameSystems.js | 66 ++++ frontend/src/version.js | 2 +- frontend/src/views/SystemInfoView.vue | 22 +- frontend/src/views/UserManagementView.vue | 8 +- frontend/src/views/UserProfileView.vue | 66 +++- 51 files changed, 3513 insertions(+), 118 deletions(-) rename backend/{config => appsystem}/handlers.go (66%) rename backend/{config => appsystem}/routes.go (82%) rename backend/{config => appsystem}/version.go (62%) rename backend/{config => appsystem}/version_test.go (60%) create mode 100644 backend/character/share_handlers_test.go create mode 100644 backend/maintenance/believe_handlers.go create mode 100644 backend/maintenance/believe_handlers_test.go create mode 100644 backend/maintenance/masterdata_handlers.go create mode 100644 backend/maintenance/masterdata_handlers_test.go create mode 100644 frontend/src/components/maintenance/BelieveView.vue create mode 100644 frontend/src/components/maintenance/GameSystemView.vue create mode 100644 frontend/src/components/maintenance/LitSourceView.vue create mode 100644 frontend/src/components/maintenance/MiscLookupView.vue create mode 100644 frontend/src/components/maintenance/SkillImprovementCostView.vue create mode 100644 frontend/src/utils/maintenanceGameSystems.js diff --git a/backend/config/handlers.go b/backend/appsystem/handlers.go similarity index 66% rename from backend/config/handlers.go rename to backend/appsystem/handlers.go index ba75953..08707c1 100644 --- a/backend/config/handlers.go +++ b/backend/appsystem/handlers.go @@ -1,4 +1,4 @@ -package config +package appsystem import ( "github.com/gin-gonic/gin" @@ -8,3 +8,7 @@ import ( func Versionsinfo(c *gin.Context) { c.JSON(200, GetInfo()) } + +func SystemInfo(c *gin.Context) { + c.JSON(200, GetInfo2()) +} diff --git a/backend/config/routes.go b/backend/appsystem/routes.go similarity index 82% rename from backend/config/routes.go rename to backend/appsystem/routes.go index d09a4fe..9234f71 100644 --- a/backend/config/routes.go +++ b/backend/appsystem/routes.go @@ -1,10 +1,11 @@ -package config +package appsystem import "github.com/gin-gonic/gin" // RegisterRoutes registers config-related routes (protected) func RegisterRoutes(r *gin.RouterGroup) { r.GET("/version", Versionsinfo) + r.GET("/systeminfo", SystemInfo) } // RegisterPublicRoutes registers public config routes (no auth required) @@ -12,4 +13,5 @@ func RegisterPublicRoutes(r *gin.Engine) { // Public version endpoint - no authentication required public := r.Group("/api/public") public.GET("/version", Versionsinfo) + public.GET("/systeminfo", SystemInfo) } diff --git a/backend/config/version.go b/backend/appsystem/version.go similarity index 62% rename from backend/config/version.go rename to backend/appsystem/version.go index e262eb6..e8d139f 100644 --- a/backend/config/version.go +++ b/backend/appsystem/version.go @@ -1,4 +1,8 @@ -package config +package appsystem + +import ( + "bamort/database" +) // Version is the application version const Version = "0.2.2" @@ -50,6 +54,22 @@ type Info struct { Version string `json:"version"` GitCommit string `json:"gitCommit"` } +type Info2 struct { + Version string `json:"version"` + GitCommit string `json:"gitCommit"` + UserCount int64 `json:"userCount"` + CharCount int64 `json:"charCount"` + DbVersion string `json:"dbVersion"` +} + +func getUserCount() int { + // Placeholder implementation + return 42 +} +func getCharCount() int { + // Placeholder implementation + return 123456 +} // GetInfo returns version information as a struct func GetInfo() Info { @@ -58,3 +78,20 @@ func GetInfo() Info { GitCommit: GitCommit, } } + +func GetInfo2() Info2 { + info := Info2{ + Version: Version, + GitCommit: GitCommit, + } + db := database.DB + if db == nil { + return info + } + + db.Table("char_chars").Count(&info.CharCount) + db.Table("users").Count(&info.UserCount) + db.Raw("SELECT version FROM schema_version ORDER BY id DESC LIMIT 1").Scan(&info.DbVersion) + + return info +} diff --git a/backend/config/version_test.go b/backend/appsystem/version_test.go similarity index 60% rename from backend/config/version_test.go rename to backend/appsystem/version_test.go index 62f702a..24f2d35 100644 --- a/backend/config/version_test.go +++ b/backend/appsystem/version_test.go @@ -1,7 +1,10 @@ -package config +package appsystem import ( "testing" + + "bamort/database" + "bamort/testutils" ) func TestGetVersion(t *testing.T) { @@ -41,3 +44,24 @@ func TestGetInfo(t *testing.T) { t.Errorf("Expected info.Version %s, got %s", Version, info.Version) } } + +func TestGetInfo2UsesDatabaseValues(t *testing.T) { + testutils.SetupTestEnvironment(t) + database.ResetTestDB() + t.Cleanup(database.ResetTestDB) + database.SetupTestDB() + + info := GetInfo2() + + if info.CharCount <= 0 { + t.Fatalf("expected character count from database, got %d", info.CharCount) + } + + if info.UserCount <= 0 { + t.Fatalf("expected user count from database, got %d", info.UserCount) + } + + if info.DbVersion == "" { + t.Fatalf("expected database schema version, got empty string") + } +} diff --git a/backend/character/handlers.go b/backend/character/handlers.go index 66855fb..81f6c0d 100644 --- a/backend/character/handlers.go +++ b/backend/character/handlers.go @@ -195,6 +195,7 @@ func ToFeChar(object *models.Char) *models.FeChar { feC.Fertigkeiten = skills feC.InnateSkills = innateSkills feC.CategorizedSkills = categories + feC.Git = object.GetGiftToleranz() return feC } diff --git a/backend/character/handlers_test.go b/backend/character/handlers_test.go index 2b5b434..35412f7 100644 --- a/backend/character/handlers_test.go +++ b/backend/character/handlers_test.go @@ -1073,9 +1073,11 @@ func TestGetDatasheetOptions(t *testing.T) { assert.Contains(t, socialClasses, "Mittelschicht") faiths := response["faiths"].([]interface{}) - assert.Equal(t, 5, len(faiths)) + assert.Equal(t, 15, len(faiths)) assert.Contains(t, faiths, "Druide") assert.Contains(t, faiths, "Keine") + assert.Contains(t, faiths, "Torkin") + assert.NotContains(t, faiths, "") handedness := response["handedness"].([]interface{}) assert.Equal(t, 3, len(handedness)) diff --git a/backend/character/share_handlers.go b/backend/character/share_handlers.go index 2c326cd..54fc52e 100644 --- a/backend/character/share_handlers.go +++ b/backend/character/share_handlers.go @@ -33,8 +33,9 @@ func GetCharacterShares(c *gin.Context) { // Get user details for each share type ShareWithUser struct { models.CharShare - Username string `json:"username"` - Email string `json:"email"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` } var sharesWithUsers []ShareWithUser @@ -42,9 +43,10 @@ func GetCharacterShares(c *gin.Context) { var u user.User if err := u.FirstId(share.UserID); err == nil { sharesWithUsers = append(sharesWithUsers, ShareWithUser{ - CharShare: share, - Username: u.Username, - Email: u.Email, + CharShare: share, + Username: u.Username, + DisplayName: u.DisplayNameOrUsername(), + Email: u.Email, }) } } @@ -128,17 +130,19 @@ func GetAvailableUsersForSharing(c *gin.Context) { // Remove sensitive data type UserInfo struct { - UserID uint `json:"user_id"` - Username string `json:"username"` - Email string `json:"email"` + UserID uint `json:"user_id"` + Username string `json:"username"` + DisplayName string `json:"display_name"` + Email string `json:"email"` } var userInfos []UserInfo for _, u := range users { userInfos = append(userInfos, UserInfo{ - UserID: u.UserID, - Username: u.Username, - Email: u.Email, + UserID: u.UserID, + Username: u.Username, + DisplayName: u.DisplayNameOrUsername(), + Email: u.Email, }) } diff --git a/backend/character/share_handlers_test.go b/backend/character/share_handlers_test.go new file mode 100644 index 0000000..cc99baf --- /dev/null +++ b/backend/character/share_handlers_test.go @@ -0,0 +1,93 @@ +package character + +import ( + "bamort/database" + "bamort/models" + "bamort/user" + "crypto/md5" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupShareHandlerTestEnvironment(t *testing.T) { + original := os.Getenv("ENVIRONMENT") + os.Setenv("ENVIRONMENT", "test") + t.Cleanup(func() { + if original != "" { + os.Setenv("ENVIRONMENT", original) + } else { + os.Unsetenv("ENVIRONMENT") + } + }) + + database.SetupTestDB(true, true) + t.Cleanup(database.ResetTestDB) + + err := user.MigrateStructure() + require.NoError(t, err, "Should migrate user structure") + + err = models.MigrateStructure() + require.NoError(t, err, "Should migrate models structure") + + gin.SetMode(gin.TestMode) +} + +func createHashedUser(t *testing.T, username, password, email, displayName string) *user.User { + u := &user.User{ + Username: username, + PasswordHash: password, + Email: email, + DisplayName: displayName, + } + + hashed := md5.Sum([]byte(password)) + u.PasswordHash = hex.EncodeToString(hashed[:]) + + err := u.Create() + require.NoError(t, err, "Should create user") + return u +} + +func TestGetAvailableUsersForSharingReturnsDisplayNames(t *testing.T) { + setupShareHandlerTestEnvironment(t) + + owner := createHashedUser(t, "owneruser", "ownerpass", "owner@example.com", "") + sharedUser := createHashedUser(t, "shareduser", "sharedpass", "shared@example.com", "Shared Display") + + char := models.Char{ + BamortBase: models.BamortBase{Name: "Shared Character"}, + UserID: owner.UserID, + } + err := database.DB.Create(&char).Error + require.NoError(t, err, "Should create character") + + req, _ := http.NewRequest("GET", fmt.Sprintf("/api/characters/%d/available-users", char.ID), nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Params = gin.Params{gin.Param{Key: "id", Value: fmt.Sprintf("%d", char.ID)}} + c.Set("userID", owner.UserID) + + GetAvailableUsersForSharing(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response []map[string]interface{} + err = json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + require.Len(t, response, 1, "Only the non-owner user should be returned") + + entry := response[0] + assert.Equal(t, float64(sharedUser.UserID), entry["user_id"]) + assert.Equal(t, sharedUser.DisplayName, entry["display_name"]) +} diff --git a/backend/cmd/main.go b/backend/cmd/main.go index 143565b..8fd25c6 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -1,6 +1,7 @@ package main import ( + "bamort/appsystem" "bamort/character" "bamort/config" "bamort/database" @@ -92,11 +93,11 @@ func main() { importer.RegisterRoutes(protected) pdfrender.RegisterRoutes(protected) transfer.RegisterRoutes(protected) - config.RegisterRoutes(protected) + appsystem.RegisterRoutes(protected) // Register public routes (no authentication) pdfrender.RegisterPublicRoutes(r) - config.RegisterPublicRoutes(r) + appsystem.RegisterPublicRoutes(r) logger.Info("API-Routen erfolgreich registriert") diff --git a/backend/gsmaster/misclookup.go b/backend/gsmaster/misclookup.go index a4780eb..477deb8 100644 --- a/backend/gsmaster/misclookup.go +++ b/backend/gsmaster/misclookup.go @@ -4,6 +4,7 @@ import ( "bamort/database" "bamort/models" "fmt" + "sort" "strings" ) @@ -19,23 +20,32 @@ func GetMiscLookupByKey(key string, order ...string) ([]models.MiscLookup, error func GetMiscLookupByKeyForSystem(key string, gameSystemId uint, order ...string) ([]models.MiscLookup, error) { var items []models.MiscLookup + orderKey := "value" orderBy := "value ASC" if len(order) > 0 && order[0] != "" { switch order[0] { case "id": + orderKey = "id" orderBy = "id ASC" case "value": + orderKey = "value" orderBy = "value ASC" case "source": + orderKey = "source" orderBy = "source_id ASC, value ASC" case "source_value": + orderKey = "source" orderBy = "source_id ASC, value ASC" default: + orderKey = "value" orderBy = "value ASC" } } gs := models.GetGameSystem(gameSystemId, "") + if gs == nil { + gs = models.GetGameSystem(0, "") + } query := database.DB.Where("`key` = ?", key) if gs.ID != 0 { @@ -57,9 +67,96 @@ func GetMiscLookupByKeyForSystem(key string, gameSystemId uint, order ...string) } } + if key == "faiths" { + existingValues := make(map[string]struct{}, len(items)) + for i := range items { + trimmed := strings.TrimSpace(items[i].Value) + if trimmed == "" { + continue + } + existingValues[trimmed] = struct{}{} + } + + believeItems, err := collectBeliefsForSystem(gs) + if err != nil { + return items, err + } + + additions := make([]models.MiscLookup, 0, len(believeItems)) + for _, believe := range believeItems { + value := strings.TrimSpace(believe.Name) + if value == "" { + continue + } + + if _, exists := existingValues[value]; exists { + continue + } + existingValues[value] = struct{}{} + + addition := models.MiscLookup{ + ID: believe.ID, + Key: key, + Value: value, + SourceID: believe.SourceID, + PageNumber: believe.PageNumber, + GameSystem: believe.GameSystem, + GameSystemId: believe.GameSystemId, + } + + if addition.GameSystemId == 0 && gs.ID != 0 { + addition.GameSystemId = gs.ID + } + + additions = append(additions, addition) + } + + if len(additions) > 0 { + items = append(items, additions...) + sortMiscLookup(items, orderKey) + } + } + return items, nil } +func collectBeliefsForSystem(gs *models.GameSystem) ([]models.Believe, error) { + var believes []models.Believe + if gs == nil { + return believes, nil + } + + query := database.DB.Model(&models.Believe{}) + if gs.ID != 0 { + query = query.Where("game_system_id = ?", gs.ID) + } + if gs.Name != "" { + query = query.Or("game_system = ?", gs.Name) + } + + if err := query.Find(&believes).Error; err != nil { + return believes, err + } + + return believes, nil +} + +func sortMiscLookup(items []models.MiscLookup, orderKey string) { + sort.SliceStable(items, func(i, j int) bool { + switch orderKey { + case "id": + return items[i].ID < items[j].ID + case "source": + if items[i].SourceID == items[j].SourceID { + return items[i].Value < items[j].Value + } + return items[i].SourceID < items[j].SourceID + default: + return items[i].Value < items[j].Value + } + }) +} + /* // PopulateMiscLookupData populates initial misc lookup data if table is empty func PopulateMiscLookupData() error { diff --git a/backend/gsmaster/misclookup_test.go b/backend/gsmaster/misclookup_test.go index 9ebc669..15ddae4 100644 --- a/backend/gsmaster/misclookup_test.go +++ b/backend/gsmaster/misclookup_test.go @@ -111,7 +111,7 @@ func TestPopulateMiscLookupData(t *testing.T) { "races": 5, "origins": 15, "social_classes": 4, - "faiths": 5, + "faiths": 15, "handedness": 3, } @@ -133,6 +133,21 @@ func TestPopulateMiscLookupData(t *testing.T) { assert.Contains(t, raceValues, "Mensch") assert.Contains(t, raceValues, "Elf") + faiths, _ := GetMiscLookupByKey("faiths") + faithValues := make([]string, len(faiths)) + for i, f := range faiths { + faithValues[i] = f.Value + } + assert.Contains(t, faithValues, "Torkin") + assert.NotContains(t, faithValues, "") + mahalCount := 0 + for _, v := range faithValues { + if v == "Mahal" { + mahalCount++ + } + } + assert.Equal(t, 1, mahalCount) + /* // Second population should not duplicate data err = PopulateMiscLookupData() diff --git a/backend/maintenance/believe_handlers.go b/backend/maintenance/believe_handlers.go new file mode 100644 index 0000000..1e1eadf --- /dev/null +++ b/backend/maintenance/believe_handlers.go @@ -0,0 +1,155 @@ +package maintenance + +import ( + "bamort/database" + "bamort/models" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +type believeResponse struct { + models.Believe + SourceCode string `json:"source_code,omitempty"` +} + +type believeUpdateRequest struct { + Name string `json:"name"` + Beschreibung string `json:"beschreibung"` + SourceID *uint `json:"source_id"` + PageNumber *int `json:"page_number"` +} + +func resolveGameSystemOrDefault(c *gin.Context) *models.GameSystem { + gsIDStr := c.Query("game_system_id") + gsName := c.Query("game_system") + + var gsID uint + if gsIDStr != "" { + id, err := strconv.ParseUint(gsIDStr, 10, 32) + if err != nil { + respondWithError(c, http.StatusBadRequest, "Invalid game_system_id") + return nil + } + gsID = uint(id) + } + + gs := models.GetGameSystem(gsID, gsName) + if gs == nil { + respondWithError(c, http.StatusBadRequest, "Invalid game system") + return nil + } + + return gs +} + +func GetBelieves(c *gin.Context) { + gs := resolveGameSystemOrDefault(c) + if gs == nil { + return + } + + var believes []models.Believe + if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID). + Order("name ASC"). + Find(&believes).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to retrieve believes") + return + } + + var sources []models.Source + if err := database.DB.Find(&sources).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to retrieve sources") + return + } + + sourceMap := make(map[uint]string, len(sources)) + for _, source := range sources { + sourceMap[source.ID] = source.Code + } + + enhanced := make([]believeResponse, len(believes)) + for i, believe := range believes { + enhanced[i] = believeResponse{ + Believe: believe, + SourceCode: sourceMap[believe.SourceID], + } + } + + c.JSON(http.StatusOK, gin.H{ + "believes": enhanced, + "sources": sources, + }) +} + +func UpdateBelieve(c *gin.Context) { + gs := resolveGameSystemOrDefault(c) + if gs == nil { + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + respondWithError(c, http.StatusBadRequest, "Invalid ID") + return + } + + var believe models.Believe + if err := database.DB.First(&believe, uint(id)).Error; err != nil { + respondWithError(c, http.StatusNotFound, "Believe not found") + return + } + + var payload believeUpdateRequest + if err := c.ShouldBindJSON(&payload); err != nil { + respondWithError(c, http.StatusBadRequest, err.Error()) + return + } + + name := strings.TrimSpace(payload.Name) + if name == "" { + respondWithError(c, http.StatusBadRequest, "name is required") + return + } + + believe.Name = name + believe.Beschreibung = payload.Beschreibung + if payload.SourceID != nil { + believe.SourceID = *payload.SourceID + } else { + believe.SourceID = 0 + } + if payload.PageNumber != nil { + believe.PageNumber = *payload.PageNumber + } else { + believe.PageNumber = 0 + } + + believe.GameSystem = gs.Name + believe.GameSystemId = gs.ID + + if err := database.DB.Save(&believe).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to update believe") + return + } + + c.JSON(http.StatusOK, believeResponse{ + Believe: believe, + SourceCode: lookupSourceCode(believe.SourceID), + }) +} + +func lookupSourceCode(sourceID uint) string { + if sourceID == 0 { + return "" + } + + var source models.Source + if err := database.DB.Select("code").First(&source, sourceID).Error; err != nil { + return "" + } + + return source.Code +} diff --git a/backend/maintenance/believe_handlers_test.go b/backend/maintenance/believe_handlers_test.go new file mode 100644 index 0000000..d591724 --- /dev/null +++ b/backend/maintenance/believe_handlers_test.go @@ -0,0 +1,160 @@ +package maintenance + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "bamort/database" + "bamort/models" + "bamort/router" + "bamort/testutils" + "bamort/user" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setupMaintenanceTest(t *testing.T) (string, *gin.Engine, *models.GameSystem) { + t.Helper() + + testutils.SetupTestEnvironment(t) + database.ResetTestDB() + t.Cleanup(database.ResetTestDB) + database.SetupTestDB(true) + + var maintainer user.User + require.NoError(t, database.DB.First(&maintainer, 1).Error) + maintainer.Role = user.RoleMaintainer + require.NoError(t, maintainer.Save()) + + token := user.GenerateToken(&maintainer) + + gin.SetMode(gin.TestMode) + r := gin.Default() + router.SetupGin(r) + protected := router.BaseRouterGrp(r) + RegisterRoutes(protected) + + gs := models.GetGameSystem(0, "") + require.NotNil(t, gs) + + return token, r, gs +} + +func createSource(t *testing.T, gs *models.GameSystem, code string) models.Source { + t.Helper() + + source := models.Source{ + Code: code, + Name: fmt.Sprintf("Source %s", code), + FullName: fmt.Sprintf("Source %s", code), + GameSystem: gs.Name, + GameSystemId: gs.ID, + IsActive: true, + } + require.NoError(t, database.DB.Create(&source).Error) + return source +} + +func createBelieve(t *testing.T, gs *models.GameSystem, source models.Source, name string) models.Believe { + t.Helper() + + believe := models.Believe{ + Name: name, + Beschreibung: "Initial description", + SourceID: source.ID, + PageNumber: 7, + GameSystem: gs.Name, + GameSystemId: gs.ID, + } + require.NoError(t, database.DB.Create(&believe).Error) + return believe +} + +func TestListBelievesReturnsData(t *testing.T) { + token, router, gs := setupMaintenanceTest(t) + source := createSource(t, gs, "TSTBEL") + created := createBelieve(t, gs, source, "Test Believe One") + + req, err := http.NewRequest(http.MethodGet, "/api/maintenance/gsm-believes", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var payload struct { + Believes []struct { + ID uint `json:"id"` + Name string `json:"name"` + SourceID uint `json:"source_id"` + SourceCode string `json:"source_code"` + } `json:"believes"` + Sources []models.Source `json:"sources"` + } + + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload)) + assert.NotEmpty(t, payload.Sources) + + var found bool + for _, b := range payload.Believes { + if b.ID == created.ID { + found = true + assert.Equal(t, created.Name, b.Name) + assert.Equal(t, source.ID, b.SourceID) + assert.Equal(t, source.Code, b.SourceCode) + } + } + + assert.True(t, found, "expected created believe in response") +} + +func TestUpdateBelieve(t *testing.T) { + token, router, gs := setupMaintenanceTest(t) + sourceOld := createSource(t, gs, "OLD01") + sourceNew := createSource(t, gs, "NEW01") + created := createBelieve(t, gs, sourceOld, "Old Believe") + + updateBody := map[string]interface{}{ + "name": "Updated Believe", + "beschreibung": "Updated description", + "source_id": sourceNew.ID, + "page_number": 123, + } + + bodyBytes, err := json.Marshal(updateBody) + require.NoError(t, err) + + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/gsm-believes/%d", created.ID), bytes.NewBuffer(bodyBytes)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var updated models.Believe + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated)) + assert.Equal(t, created.ID, updated.ID) + assert.Equal(t, "Updated Believe", updated.Name) + assert.Equal(t, "Updated description", updated.Beschreibung) + assert.Equal(t, sourceNew.ID, updated.SourceID) + assert.Equal(t, 123, updated.PageNumber) + assert.Equal(t, gs.Name, updated.GameSystem) + assert.Equal(t, gs.ID, updated.GameSystemId) + + var stored models.Believe + require.NoError(t, database.DB.First(&stored, created.ID).Error) + assert.Equal(t, "Updated Believe", stored.Name) + assert.Equal(t, sourceNew.ID, stored.SourceID) + assert.Equal(t, 123, stored.PageNumber) +} diff --git a/backend/maintenance/masterdata_handlers.go b/backend/maintenance/masterdata_handlers.go new file mode 100644 index 0000000..b6c6ff0 --- /dev/null +++ b/backend/maintenance/masterdata_handlers.go @@ -0,0 +1,356 @@ +package maintenance + +import ( + "bamort/database" + "bamort/models" + "net/http" + "strconv" + "strings" + + "github.com/gin-gonic/gin" +) + +func GetGameSystems(c *gin.Context) { + var systems []models.GameSystem + if err := database.DB.Order("code ASC").Find(&systems).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to retrieve game systems") + return + } + + c.JSON(http.StatusOK, gin.H{"game_systems": systems}) +} + +type gameSystemUpdateRequest struct { + Name string `json:"name"` + Description string `json:"description"` + IsActive *bool `json:"is_active"` +} + +func UpdateGameSystem(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + respondWithError(c, http.StatusBadRequest, "Invalid ID") + return + } + + var gs models.GameSystem + if err := database.DB.First(&gs, uint(id)).Error; err != nil { + respondWithError(c, http.StatusNotFound, "Game system not found") + return + } + + var payload gameSystemUpdateRequest + if err := c.ShouldBindJSON(&payload); err != nil { + respondWithError(c, http.StatusBadRequest, err.Error()) + return + } + + name := strings.TrimSpace(payload.Name) + if name != "" { + gs.Name = name + } + gs.Description = payload.Description + if payload.IsActive != nil { + gs.IsActive = *payload.IsActive + } + + if err := database.DB.Save(&gs).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to update game system") + return + } + + c.JSON(http.StatusOK, gs) +} + +// --- Sources --- + +type sourceUpdateRequest struct { + Name string `json:"name"` + FullName string `json:"full_name"` + Edition string `json:"edition"` + Publisher string `json:"publisher"` + PublishYear int `json:"publish_year"` + Description string `json:"description"` + IsCore *bool `json:"is_core"` + IsActive *bool `json:"is_active"` +} + +func GetLitSources(c *gin.Context) { + gs := resolveGameSystemOrDefault(c) + if gs == nil { + return + } + + var sources []models.Source + if err := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID). + Order("code ASC"). + Find(&sources).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to retrieve sources") + return + } + + c.JSON(http.StatusOK, gin.H{"sources": sources}) +} + +func UpdateLitSource(c *gin.Context) { + gs := resolveGameSystemOrDefault(c) + if gs == nil { + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + respondWithError(c, http.StatusBadRequest, "Invalid ID") + return + } + + var src models.Source + if err := database.DB.First(&src, uint(id)).Error; err != nil { + respondWithError(c, http.StatusNotFound, "Source not found") + return + } + + var payload sourceUpdateRequest + if err := c.ShouldBindJSON(&payload); err != nil { + respondWithError(c, http.StatusBadRequest, err.Error()) + return + } + + if name := strings.TrimSpace(payload.Name); name != "" { + src.Name = name + } + src.FullName = payload.FullName + src.Edition = payload.Edition + src.Publisher = payload.Publisher + src.PublishYear = payload.PublishYear + src.Description = payload.Description + if payload.IsCore != nil { + src.IsCore = *payload.IsCore + } + if payload.IsActive != nil { + src.IsActive = *payload.IsActive + } + src.GameSystem = gs.Name + src.GameSystemId = gs.ID + + if err := database.DB.Save(&src).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to update source") + return + } + + c.JSON(http.StatusOK, src) +} + +// --- Misc lookup --- + +type miscUpdateRequest struct { + Key string `json:"key"` + Value string `json:"value"` + SourceID *uint `json:"source_id"` + PageNumber *int `json:"page_number"` +} + +func GetMisc(c *gin.Context) { + gs := resolveGameSystemOrDefault(c) + if gs == nil { + return + } + + keyFilter := strings.TrimSpace(c.Query("key")) + + var items []models.MiscLookup + q := database.DB.Where("game_system=? OR game_system_id=?", gs.Name, gs.ID) + if keyFilter != "" { + q = q.Where("`key` = ?", keyFilter) + } + if err := q.Order("`key` ASC, value ASC").Find(&items).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to retrieve misc") + return + } + + c.JSON(http.StatusOK, gin.H{"misc": items}) +} + +func UpdateMisc(c *gin.Context) { + gs := resolveGameSystemOrDefault(c) + if gs == nil { + return + } + + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + respondWithError(c, http.StatusBadRequest, "Invalid ID") + return + } + + var item models.MiscLookup + if err := database.DB.First(&item, uint(id)).Error; err != nil { + respondWithError(c, http.StatusNotFound, "Misc entry not found") + return + } + + var payload miscUpdateRequest + if err := c.ShouldBindJSON(&payload); err != nil { + respondWithError(c, http.StatusBadRequest, err.Error()) + return + } + + if key := strings.TrimSpace(payload.Key); key != "" { + item.Key = key + } + if payload.Value != "" { + item.Value = payload.Value + } + if payload.SourceID != nil { + item.SourceID = *payload.SourceID + } + if payload.PageNumber != nil { + item.PageNumber = *payload.PageNumber + } + item.GameSystem = gs.Name + item.GameSystemId = gs.ID + + if err := database.DB.Save(&item).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to update misc entry") + return + } + + c.JSON(http.StatusOK, item) +} + +// --- Skill improvement cost2 --- + +type skillImprovementUpdateRequest struct { + CurrentLevel *int `json:"current_level"` + TERequired *int `json:"te_required"` + CategoryID *uint `json:"category_id"` + DifficultyID *uint `json:"difficulty_id"` +} + +func GetSkillImprovementCost2(c *gin.Context) { + var costs []models.SkillImprovementCost + if err := database.DB.Order("current_level ASC").Find(&costs).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to retrieve skill improvement costs") + return + } + + categoryNames, difficultyNames, err := loadSkillMetadata(costs) + if err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to retrieve skill improvement costs") + return + } + + responses := make([]skillImprovementCostResponse, len(costs)) + for i, cost := range costs { + responses[i] = skillImprovementCostResponse{ + SkillImprovementCost: cost, + CategoryName: categoryNames[cost.CategoryID], + DifficultyName: difficultyNames[cost.DifficultyID], + } + } + + c.JSON(http.StatusOK, gin.H{"costs": responses}) +} + +func UpdateSkillImprovementCost2(c *gin.Context) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + respondWithError(c, http.StatusBadRequest, "Invalid ID") + return + } + + var cost models.SkillImprovementCost + if err := database.DB.First(&cost, uint(id)).Error; err != nil { + respondWithError(c, http.StatusNotFound, "Skill improvement cost not found") + return + } + + var payload skillImprovementUpdateRequest + if err := c.ShouldBindJSON(&payload); err != nil { + respondWithError(c, http.StatusBadRequest, err.Error()) + return + } + + if payload.CurrentLevel != nil { + cost.CurrentLevel = *payload.CurrentLevel + } + if payload.TERequired != nil { + cost.TERequired = *payload.TERequired + } + if payload.CategoryID != nil { + cost.CategoryID = *payload.CategoryID + } + if payload.DifficultyID != nil { + cost.DifficultyID = *payload.DifficultyID + } + + if err := database.DB.Save(&cost).Error; err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to update skill improvement cost") + return + } + + categoryNames, difficultyNames, err := loadSkillMetadata([]models.SkillImprovementCost{cost}) + if err != nil { + respondWithError(c, http.StatusInternalServerError, "Failed to update skill improvement cost") + return + } + + c.JSON(http.StatusOK, skillImprovementCostResponse{ + SkillImprovementCost: cost, + CategoryName: categoryNames[cost.CategoryID], + DifficultyName: difficultyNames[cost.DifficultyID], + }) +} + +type skillImprovementCostResponse struct { + models.SkillImprovementCost + CategoryName string `json:"category_name,omitempty"` + DifficultyName string `json:"difficulty_name,omitempty"` +} + +func loadSkillMetadata(costs []models.SkillImprovementCost) (map[uint]string, map[uint]string, error) { + categoryNames := make(map[uint]string) + difficultyNames := make(map[uint]string) + + if len(costs) == 0 { + return categoryNames, difficultyNames, nil + } + + categoryIDs := make([]uint, 0) + difficultyIDs := make([]uint, 0) + seenCategories := make(map[uint]struct{}) + seenDifficulties := make(map[uint]struct{}) + + for _, cost := range costs { + if _, ok := seenCategories[cost.CategoryID]; !ok { + seenCategories[cost.CategoryID] = struct{}{} + categoryIDs = append(categoryIDs, cost.CategoryID) + } + if _, ok := seenDifficulties[cost.DifficultyID]; !ok { + seenDifficulties[cost.DifficultyID] = struct{}{} + difficultyIDs = append(difficultyIDs, cost.DifficultyID) + } + } + + if len(categoryIDs) > 0 { + var categories []models.SkillCategory + if err := database.DB.Where("id IN ?", categoryIDs).Find(&categories).Error; err != nil { + return nil, nil, err + } + for _, category := range categories { + categoryNames[category.ID] = category.Name + } + } + + if len(difficultyIDs) > 0 { + var difficulties []models.SkillDifficulty + if err := database.DB.Where("id IN ?", difficultyIDs).Find(&difficulties).Error; err != nil { + return nil, nil, err + } + for _, difficulty := range difficulties { + difficultyNames[difficulty.ID] = difficulty.Name + } + } + + return categoryNames, difficultyNames, nil +} diff --git a/backend/maintenance/masterdata_handlers_test.go b/backend/maintenance/masterdata_handlers_test.go new file mode 100644 index 0000000..083f21b --- /dev/null +++ b/backend/maintenance/masterdata_handlers_test.go @@ -0,0 +1,292 @@ +package maintenance + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "bamort/database" + "bamort/models" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func createGameSystem(t *testing.T, code string) models.GameSystem { + t.Helper() + gs := models.GameSystem{Code: code, Name: "Game System " + code, Description: "desc", IsActive: true} + require.NoError(t, database.DB.Create(&gs).Error) + return gs +} + +func TestListGameSystems(t *testing.T) { + token, router, _ := setupMaintenanceTest(t) + created := createGameSystem(t, "TSTGS") + + req, err := http.NewRequest(http.MethodGet, "/api/maintenance/game-systems", nil) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var payload struct { + GameSystems []models.GameSystem `json:"game_systems"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload)) + + var found bool + for _, gs := range payload.GameSystems { + if gs.ID == created.ID { + found = true + assert.Equal(t, created.Code, gs.Code) + assert.Equal(t, created.Name, gs.Name) + } + } + assert.True(t, found, "expected created game system in response") +} + +func TestUpdateGameSystem(t *testing.T) { + token, router, _ := setupMaintenanceTest(t) + gs := createGameSystem(t, "UPDGS") + + body := map[string]interface{}{ + "name": "Updated GS", + "description": "Updated desc", + "is_active": false, + } + bodyBytes, _ := json.Marshal(body) + + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/game-systems/%d", gs.ID), bytes.NewBuffer(bodyBytes)) + require.NoError(t, err) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var updated models.GameSystem + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated)) + assert.Equal(t, gs.ID, updated.ID) + assert.Equal(t, "Updated GS", updated.Name) + assert.Equal(t, "Updated desc", updated.Description) + assert.False(t, updated.IsActive) +} + +func createLitSource(t *testing.T, gs models.GameSystem, code string) models.Source { + t.Helper() + src := models.Source{ + Code: code, + Name: "Source " + code, + FullName: "Full " + code, + Edition: "1", + Publisher: "Pub", + PublishYear: 2025, + Description: "Desc", + IsCore: false, + IsActive: true, + GameSystem: gs.Name, + GameSystemId: gs.ID, + } + require.NoError(t, database.DB.Create(&src).Error) + return src +} + +func TestListLitSources(t *testing.T) { + token, router, gs := setupMaintenanceTest(t) + src := createLitSource(t, *gs, "SRC01") + + req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/gsm-lit-sources", nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var payload struct { + Sources []models.Source `json:"sources"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload)) + + var found bool + for _, s := range payload.Sources { + if s.ID == src.ID { + found = true + assert.Equal(t, src.Code, s.Code) + assert.Equal(t, src.Name, s.Name) + } + } + assert.True(t, found) +} + +func TestUpdateLitSource(t *testing.T) { + token, router, gs := setupMaintenanceTest(t) + src := createLitSource(t, *gs, "SRCUPD") + + body := map[string]interface{}{ + "name": "Updated Source", + "full_name": "Updated Full", + "edition": "2", + "publisher": "NewPub", + "publish_year": 2026, + "description": "New Desc", + "is_active": false, + "is_core": true, + } + payload, _ := json.Marshal(body) + + req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/gsm-lit-sources/%d", src.ID), bytes.NewBuffer(payload)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var updated models.Source + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated)) + assert.Equal(t, "Updated Source", updated.Name) + assert.Equal(t, "Updated Full", updated.FullName) + assert.Equal(t, "2", updated.Edition) + assert.Equal(t, "NewPub", updated.Publisher) + assert.Equal(t, 2026, updated.PublishYear) + assert.False(t, updated.IsActive) + assert.True(t, updated.IsCore) +} + +func createMisc(t *testing.T, gs models.GameSystem, key, value string) models.MiscLookup { + t.Helper() + m := models.MiscLookup{ + Key: key, + Value: value, + GameSystem: gs.Name, + GameSystemId: gs.ID, + } + require.NoError(t, database.DB.Create(&m).Error) + return m +} + +func TestListMisc(t *testing.T) { + token, router, gs := setupMaintenanceTest(t) + item := createMisc(t, *gs, "origin", "North") + + req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/gsm-misc", nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var payload struct { + Items []models.MiscLookup `json:"misc"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload)) + + var found bool + for _, it := range payload.Items { + if it.ID == item.ID { + found = true + assert.Equal(t, "origin", it.Key) + assert.Equal(t, "North", it.Value) + } + } + assert.True(t, found) +} + +func TestUpdateMisc(t *testing.T) { + token, router, gs := setupMaintenanceTest(t) + item := createMisc(t, *gs, "race", "Human") + + body := map[string]interface{}{ + "value": "Elf", + } + payload, _ := json.Marshal(body) + + req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/gsm-misc/%d", item.ID), bytes.NewBuffer(payload)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var updated models.MiscLookup + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated)) + assert.Equal(t, "Elf", updated.Value) +} + +func createSkillImprovementCost(t *testing.T, gs models.GameSystem) models.SkillImprovementCost { + t.Helper() + cost := models.SkillImprovementCost{ + CurrentLevel: 5, + TERequired: 2, + CategoryID: 1, + DifficultyID: 1, + } + require.NoError(t, database.DB.Create(&cost).Error) + return cost +} + +func TestListSkillImprovementCost(t *testing.T) { + token, router, gs := setupMaintenanceTest(t) + created := createSkillImprovementCost(t, *gs) + + req, _ := http.NewRequest(http.MethodGet, "/api/maintenance/skill-improvement-cost2", nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var payload struct { + Costs []models.SkillImprovementCost `json:"costs"` + } + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &payload)) + + var found bool + for _, c := range payload.Costs { + if c.ID == created.ID { + found = true + assert.Equal(t, created.CurrentLevel, c.CurrentLevel) + assert.Equal(t, created.TERequired, c.TERequired) + } + } + assert.True(t, found) +} + +func TestUpdateSkillImprovementCost(t *testing.T) { + token, router, gs := setupMaintenanceTest(t) + created := createSkillImprovementCost(t, *gs) + + body := map[string]interface{}{ + "te_required": 5, + "current_level": 6, + } + payload, _ := json.Marshal(body) + + req, _ := http.NewRequest(http.MethodPut, fmt.Sprintf("/api/maintenance/skill-improvement-cost2/%d", created.ID), bytes.NewBuffer(payload)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + require.Equal(t, http.StatusOK, resp.Code) + + var updated models.SkillImprovementCost + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &updated)) + assert.Equal(t, 5, updated.TERequired) + assert.Equal(t, 6, updated.CurrentLevel) +} diff --git a/backend/maintenance/routes.go b/backend/maintenance/routes.go index 780515a..a2fbf4b 100644 --- a/backend/maintenance/routes.go +++ b/backend/maintenance/routes.go @@ -10,6 +10,16 @@ func RegisterRoutes(r *gin.RouterGroup) { charGrp := r.Group("/maintenance") charGrp.Use(user.RequireMaintainer()) { + charGrp.GET("/gsm-believes", GetBelieves) + charGrp.PUT("/gsm-believes/:id", UpdateBelieve) + charGrp.GET("/game-systems", GetGameSystems) + charGrp.PUT("/game-systems/:id", UpdateGameSystem) + charGrp.GET("/gsm-lit-sources", GetLitSources) + charGrp.PUT("/gsm-lit-sources/:id", UpdateLitSource) + charGrp.GET("/gsm-misc", GetMisc) + charGrp.PUT("/gsm-misc/:id", UpdateMisc) + charGrp.GET("/skill-improvement-cost2", GetSkillImprovementCost2) + charGrp.PUT("/skill-improvement-cost2/:id", UpdateSkillImprovementCost2) charGrp.GET("/setupcheck", SetupCheck) charGrp.GET("/setupcheck-dev", SetupCheckDev) charGrp.GET("/mktestdata", MakeTestdataFromLive) diff --git a/backend/models/model_character.go b/backend/models/model_character.go index 2ae3775..fb20725 100644 --- a/backend/models/model_character.go +++ b/backend/models/model_character.go @@ -131,6 +131,7 @@ type CharList struct { type FeChar struct { Char + Git int `json:"git"` // GiftToleranz CategorizedSkills map[string][]SkFertigkeit `json:"categorizedskills"` InnateSkills []SkFertigkeit `json:"innateskills"` } @@ -140,6 +141,11 @@ func (object *Char) TableName() string { return dbPrefix + "_" + "chars" } +func (object *Char) GetGiftToleranz() int { + //30+(Konstitution/2) + konstitution := object.GetAttributeValue("Ko") + return 30 + (konstitution / 2) +} func (object *Char) ensureGameSystem() { gs := GetGameSystem(object.GameSystemId, object.GameSystem) if gs == nil { @@ -259,7 +265,7 @@ func FindSharedCharList(userID uint) ([]CharList, error) { var chars []CharList gs := GetGameSystem(0, "midgard") err := database.DB.Table("char_chars"). - Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, users.username as owner"). + Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, COALESCE(NULLIF(users.display_name, ''), users.username) as owner"). Joins("LEFT JOIN users ON char_chars.user_id = users.user_id"). Joins("INNER JOIN char_shares ON char_shares.character_id = char_chars.id"). Where("char_shares.user_id = ? AND (char_chars.game_system = ? OR char_chars.game_system_id = ?)", userID, gs.Name, gs.ID). @@ -274,7 +280,7 @@ func FindPublicCharList() ([]CharList, error) { var chars []CharList gs := GetGameSystem(0, "midgard") err := database.DB.Table("char_chars"). - Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, users.username as owner"). + Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, COALESCE(NULLIF(users.display_name, ''), users.username) as owner"). Joins("LEFT JOIN users ON char_chars.user_id = users.user_id"). Where("char_chars.public = ? AND (char_chars.game_system = ? OR char_chars.game_system_id = ?)", true, gs.Name, gs.ID). Find(&chars).Error @@ -289,7 +295,7 @@ func FindCharListByUserID(userID uint) ([]CharList, error) { var chars []CharList gs := GetGameSystem(0, "midgard") err := database.DB.Table("char_chars"). - Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, users.username as owner"). + Select("char_chars.id, char_chars.name, char_chars.user_id, char_chars.rasse, char_chars.typ, char_chars.grad, char_chars.public, char_chars.game_system, char_chars.game_system_id, COALESCE(NULLIF(users.display_name, ''), users.username) as owner"). Joins("LEFT JOIN users ON char_chars.user_id = users.user_id"). Where("char_chars.user_id = ? AND (char_chars.game_system = ? OR char_chars.game_system_id = ?)", userID, gs.Name, gs.ID). Find(&chars).Error diff --git a/backend/models/model_character_test.go b/backend/models/model_character_test.go index 6aca03c..9371d4a 100644 --- a/backend/models/model_character_test.go +++ b/backend/models/model_character_test.go @@ -13,8 +13,11 @@ import ( func setupCharacterTestDB(t *testing.T) { database.SetupTestDB() + err := user.MigrateStructure() + require.NoError(t, err, "Failed to migrate user structure") + // Migrate structures - err := MigrateStructure() + err = MigrateStructure() require.NoError(t, err, "Failed to migrate database structure") // Clean up any existing test data @@ -355,6 +358,40 @@ func TestFindCharListByUserID_Success(t *testing.T) { } } +func TestFindPublicCharList_UsesOwnerDisplayName(t *testing.T) { + setupCharacterTestDB(t) + + displayOwner := &user.User{ + Username: "display-owner", + DisplayName: "Display Owner", + PasswordHash: "hash", + Email: "display-owner@example.com", + } + err := displayOwner.Create() + require.NoError(t, err, "User creation should succeed") + + char := createTestChar("Public Char With Display Name") + char.UserID = displayOwner.UserID + char.Public = true + + err = char.Create() + require.NoError(t, err, "Character creation should succeed") + + publicChars, err := FindPublicCharList() + require.NoError(t, err, "FindPublicCharList should succeed") + + var found *CharList + for i := range publicChars { + if publicChars[i].ID == char.ID { + found = &publicChars[i] + break + } + } + + require.NotNil(t, found, "Created public character should be present in list") + assert.Equal(t, displayOwner.DisplayName, found.Owner, "Owner should use display name") +} + // ============================================================================= // Tests for Eigenschaft struct // ============================================================================= diff --git a/backend/transfer/database.go b/backend/transfer/database.go index d6a9baa..a806467 100644 --- a/backend/transfer/database.go +++ b/backend/transfer/database.go @@ -1,7 +1,7 @@ package transfer import ( - "bamort/config" + "bamort/appsystem" "bamort/database" "bamort/models" "bamort/user" @@ -90,7 +90,7 @@ func ExportDatabase(exportDir string) (*ExportResult, error) { } export := DatabaseExport{ - Version: config.GetVersion(), + Version: appsystem.GetVersion(), Timestamp: time.Now(), } diff --git a/backend/user/admin_handlers.go b/backend/user/admin_handlers.go index 26b6f0a..b0b3db4 100644 --- a/backend/user/admin_handlers.go +++ b/backend/user/admin_handlers.go @@ -27,6 +27,7 @@ func ListUsers(c *gin.Context) { for i := range users { users[i].PasswordHash = "" users[i].ResetPwHash = nil + users[i].DisplayName = users[i].DisplayNameOrUsername() } logger.Info("Successfully fetched %d users", len(users)) @@ -77,6 +78,7 @@ func GetUser(c *gin.Context) { // Remove sensitive data user.PasswordHash = "" user.ResetPwHash = nil + user.DisplayName = user.DisplayNameOrUsername() logger.Info("Successfully fetched user: %s (ID: %d)", user.Username, user.UserID) c.JSON(http.StatusOK, user) @@ -135,9 +137,10 @@ func UpdateUserRole(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "message": "User role updated successfully", "user": gin.H{ - "id": user.UserID, - "username": user.Username, - "role": user.Role, + "id": user.UserID, + "username": user.Username, + "display_name": user.DisplayNameOrUsername(), + "role": user.Role, }, }) } diff --git a/backend/user/handlers.go b/backend/user/handlers.go index 0eb6d73..2903cab 100644 --- a/backend/user/handlers.go +++ b/backend/user/handlers.go @@ -14,6 +14,7 @@ import ( "net/http" "strconv" "strings" + "unicode/utf8" "github.com/gin-gonic/gin" ) @@ -414,9 +415,10 @@ func ValidateResetToken(c *gin.Context) { logger.Debug("Reset-Token gültig für Benutzer: %s", user.Username) c.JSON(http.StatusOK, gin.H{ - "valid": true, - "username": user.Username, - "expires": user.ResetPwHashExpires, + "valid": true, + "username": user.Username, + "display_name": user.DisplayNameOrUsername(), + "expires": user.ResetPwHashExpires, }) } @@ -443,6 +445,7 @@ func GetUserProfile(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ "id": user.UserID, "username": user.Username, + "display_name": user.DisplayNameOrUsername(), "email": user.Email, "role": user.Role, "preferred_language": user.PreferredLanguage, @@ -605,3 +608,51 @@ func UpdateLanguage(c *gin.Context) { "language": user.PreferredLanguage, }) } + +// UpdateDisplayName Handler to update user's display name +func UpdateDisplayName(c *gin.Context) { + logger.Debug("Starte Anzeigenamen-Aktualisierung...") + + userID, exists := c.Get("userID") + if !exists { + logger.Error("Benutzer-ID nicht im Context gefunden") + respondWithError(c, http.StatusUnauthorized, "Unauthorized") + return + } + + var input struct { + DisplayName string `json:"display_name"` + } + + if err := c.ShouldBindJSON(&input); err != nil { + logger.Error("Fehler beim Parsen des Anzeigenamens: %s", err.Error()) + respondWithError(c, http.StatusBadRequest, "Display name is required") + return + } + + if utf8.RuneCountInString(input.DisplayName) > 30 { + logger.Warn("Anzeigename zu lang: %d Zeichen", utf8.RuneCountInString(input.DisplayName)) + respondWithError(c, http.StatusBadRequest, "Display name must be at most 30 characters") + return + } + + var user User + if err := user.FirstId(userID.(uint)); err != nil { + logger.Error("Benutzer mit ID %v nicht gefunden: %s", userID, err.Error()) + respondWithError(c, http.StatusNotFound, "User not found") + return + } + + user.DisplayName = input.DisplayName + if err := user.Save(); err != nil { + logger.Error("Fehler beim Speichern des Anzeigenamens für Benutzer %s: %s", user.Username, err.Error()) + respondWithError(c, http.StatusInternalServerError, "Failed to update display name") + return + } + + logger.Info("Anzeigename erfolgreich aktualisiert für Benutzer: %s (ID: %d)", user.Username, user.UserID) + c.JSON(http.StatusOK, gin.H{ + "message": "Display name updated successfully", + "display_name": user.DisplayNameOrUsername(), + }) +} diff --git a/backend/user/handlers_test.go b/backend/user/handlers_test.go index c2ff7ee..abfaf6d 100644 --- a/backend/user/handlers_test.go +++ b/backend/user/handlers_test.go @@ -9,6 +9,7 @@ import ( "net/http" "net/http/httptest" "os" + "strings" "testing" "bamort/database" @@ -31,6 +32,83 @@ func setupTestEnvironment(t *testing.T) { }) } +func TestUpdateDisplayName(t *testing.T) { + setupHandlerTestEnvironment(t) + + t.Run("Success - Update display name", func(t *testing.T) { + user := createTestUser(t, "displayuser", "password123", "display@test.com") + + requestData := map[string]interface{}{ + "display_name": "Neuer Anzeigename", + } + requestBody, _ := json.Marshal(requestData) + + req, _ := http.NewRequest("PUT", "/api/user/display-name", bytes.NewBuffer(requestBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("userID", user.UserID) + + UpdateDisplayName(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Display name updated successfully", response["message"]) + assert.Equal(t, "Neuer Anzeigename", response["display_name"]) + + var updated User + err = updated.FirstId(user.UserID) + assert.NoError(t, err) + assert.Equal(t, "Neuer Anzeigename", updated.DisplayName) + }) + + t.Run("Failure - Display name too long", func(t *testing.T) { + user := createTestUser(t, "displayuser2", "password123", "display2@test.com") + + requestData := map[string]interface{}{ + "display_name": strings.Repeat("a", 31), + } + requestBody, _ := json.Marshal(requestData) + + req, _ := http.NewRequest("PUT", "/api/user/display-name", bytes.NewBuffer(requestBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("userID", user.UserID) + + UpdateDisplayName(c) + + assert.Equal(t, http.StatusBadRequest, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "Display name must be at most 30 characters", response["error"]) + }) + + t.Run("Failure - No user ID in context", func(t *testing.T) { + requestData := map[string]interface{}{ + "display_name": "Any", + } + requestBody, _ := json.Marshal(requestData) + + req, _ := http.NewRequest("PUT", "/api/user/display-name", bytes.NewBuffer(requestBody)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + + UpdateDisplayName(c) + + assert.Equal(t, http.StatusUnauthorized, w.Code) + }) +} + // setupHandlerTestEnvironment sets up the test environment for handler tests func setupHandlerTestEnvironment(t *testing.T) { setupTestEnvironment(t) @@ -89,6 +167,45 @@ func TestGetUserProfile(t *testing.T) { assert.Equal(t, float64(testUser.UserID), response["id"]) }) + t.Run("Success - Profile uses display name with fallback", func(t *testing.T) { + userWithDisplay := createTestUser(t, "profiledisplay", "password123", "profiledisplay@test.com") + + req, _ := http.NewRequest("GET", "/api/user/profile", nil) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = req + c.Set("userID", userWithDisplay.UserID) + + GetUserProfile(c) + + assert.Equal(t, http.StatusOK, w.Code) + + var response map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + + assert.Equal(t, userWithDisplay.Username, response["display_name"]) + + userWithDisplay.DisplayName = "Profile Display" + require.NoError(t, userWithDisplay.Save()) + + req2, _ := http.NewRequest("GET", "/api/user/profile", nil) + w2 := httptest.NewRecorder() + c2, _ := gin.CreateTestContext(w2) + c2.Request = req2 + c2.Set("userID", userWithDisplay.UserID) + + GetUserProfile(c2) + + assert.Equal(t, http.StatusOK, w2.Code) + + var response2 map[string]interface{} + err = json.Unmarshal(w2.Body.Bytes(), &response2) + assert.NoError(t, err) + + assert.Equal(t, userWithDisplay.DisplayName, response2["display_name"]) + }) + t.Run("Failure - No user ID in context", func(t *testing.T) { req, _ := http.NewRequest("GET", "/api/user/profile", nil) w := httptest.NewRecorder() diff --git a/backend/user/model.go b/backend/user/model.go index 5acc604..d78a310 100644 --- a/backend/user/model.go +++ b/backend/user/model.go @@ -3,6 +3,7 @@ package user import ( "bamort/database" "fmt" + "strings" "time" "gorm.io/gorm" @@ -18,6 +19,7 @@ const ( type User struct { UserID uint `gorm:"primaryKey" json:"id"` Username string `gorm:"unique" json:"username"` + DisplayName string `gorm:"not null;default:''" json:"display_name"` PasswordHash string `json:"password"` Email string `gorm:"unique" json:"email"` Role string `gorm:"default:standard" json:"role"` @@ -33,6 +35,10 @@ func (object *User) Create() error { return fmt.Errorf("database connection is nil") } + if strings.TrimSpace(object.DisplayName) == "" { + object.DisplayName = object.Username + } + err := database.DB.Transaction(func(tx *gorm.DB) error { // Save the User record if err := tx.Create(&object).Error; err != nil { @@ -75,6 +81,10 @@ func (object *User) Save() error { return fmt.Errorf("database connection is nil") } + if strings.TrimSpace(object.DisplayName) == "" { + object.DisplayName = object.Username + } + err := database.DB.Save(&object).Error if err != nil { // User found @@ -154,3 +164,10 @@ func (u *User) IsStandardUser() bool { func ValidateRole(role string) bool { return role == RoleStandardUser || role == RoleMaintainer || role == RoleAdmin } + +func (u *User) DisplayNameOrUsername() string { + if strings.TrimSpace(u.DisplayName) != "" { + return u.DisplayName + } + return u.Username +} diff --git a/backend/user/role_test.go b/backend/user/role_test.go index abcbf50..114d230 100644 --- a/backend/user/role_test.go +++ b/backend/user/role_test.go @@ -39,6 +39,21 @@ func TestUserRoleDefaults(t *testing.T) { assert.Equal(t, RoleStandardUser, user.Role, "New users should have standard role") } +// TestUserDisplayNameDefaultsToUsername ensures a user's display name falls back to the username +func TestUserDisplayNameDefaultsToUsername(t *testing.T) { + setupRoleTestEnvironment(t) + + user := &User{ + Username: "display_user", + PasswordHash: "hashedpw", + Email: "display@example.com", + } + + err := user.Create() + require.NoError(t, err, "Should create user") + assert.Equal(t, user.Username, user.DisplayName, "DisplayName should default to username") +} + // TestRoleValidation tests role validation func TestRoleValidation(t *testing.T) { assert.True(t, ValidateRole(RoleStandardUser), "standard should be valid") @@ -75,7 +90,7 @@ func TestListUsers(t *testing.T) { setupRoleTestEnvironment(t) // Create test users - admin := &User{Username: "listadmin", PasswordHash: "hash", Email: "listadmin@test.com", Role: RoleAdmin} + admin := &User{Username: "listadmin", PasswordHash: "hash", Email: "listadmin@test.com", Role: RoleAdmin, DisplayName: "List Admin"} require.NoError(t, admin.Create()) standardUser := &User{Username: "listuser", PasswordHash: "hash", Email: "listuser@test.com", Role: RoleStandardUser} @@ -93,6 +108,27 @@ func TestListUsers(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code, "Admin should access list") + var response []map[string]interface{} + err := json.Unmarshal(w.Body.Bytes(), &response) + require.NoError(t, err) + + var adminEntry map[string]interface{} + var standardEntry map[string]interface{} + for _, entry := range response { + switch entry["username"] { + case admin.Username: + adminEntry = entry + case standardUser.Username: + standardEntry = entry + } + } + + require.NotNil(t, adminEntry, "admin should be present in response") + require.NotNil(t, standardEntry, "standard user should be present in response") + + assert.Equal(t, admin.DisplayName, adminEntry["display_name"], "admin display name should be returned") + assert.Equal(t, standardUser.Username, standardEntry["display_name"], "standard user should fall back to username when display name is empty") + // Test standard user access (should fail) router2 := gin.Default() router2.GET("/users", func(c *gin.Context) { diff --git a/backend/user/routes.go b/backend/user/routes.go index 3ba20ef..38966ad 100644 --- a/backend/user/routes.go +++ b/backend/user/routes.go @@ -10,6 +10,7 @@ func RegisterRoutes(r *gin.RouterGroup) { { // Protected routes - require authentication userGroup.GET("/profile", GetUserProfile) + userGroup.PUT("/display-name", UpdateDisplayName) userGroup.PUT("/email", UpdateEmail) userGroup.PUT("/password", UpdatePassword) userGroup.PUT("/language", UpdateLanguage) diff --git a/frontend/VERSION.md b/frontend/VERSION.md index d0c68ac..78c7a87 100644 --- a/frontend/VERSION.md +++ b/frontend/VERSION.md @@ -1,6 +1,6 @@ # Frontend Version Management -## Current Version: 0.2.1 +## Current Version: 0.2.2 The frontend version is managed independently from the backend. diff --git a/frontend/package.json b/frontend/package.json index 78f3ca9..1018dc2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "bamort-frontend", - "version": "0.2.1", + "version": "0.2.2", "private": true, "license": "SEE LICENSE IN LICENSE", "type": "module", diff --git a/frontend/src/components/CharacterCreation/CharacterBasicInfo.vue b/frontend/src/components/CharacterCreation/CharacterBasicInfo.vue index f4f2666..11e1a77 100644 --- a/frontend/src/components/CharacterCreation/CharacterBasicInfo.vue +++ b/frontend/src/components/CharacterCreation/CharacterBasicInfo.vue @@ -6,7 +6,16 @@
+ {{ $t('char') }}: + {{ character.name || '-' }} + +
{{ character.glaube || '-' }} -
@@ -190,8 +217,12 @@| {{ $t('litsource.id') }} | +{{ $t('litsource.code') }} | +{{ $t('litsource.name') }} | +{{ $t('litsource.fullName') }} | +{{ $t('litsource.edition') }} | +{{ $t('litsource.publisher') }} | +{{ $t('litsource.year') }} | +{{ $t('litsource.active') }} | +{{ $t('litsource.core') }} | ++ |
|---|---|---|---|---|---|---|---|---|---|
| {{ $t('common.loading') }} | +|||||||||
| {{ src.id }} | +{{ src.code }} | +{{ src.name }} | +{{ src.full_name }} | +{{ src.edition }} | +{{ src.publisher }} | +{{ src.publish_year }} | ++ | + | + |
| {{ src.id }} | +{{ src.code }} | +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |||||||
| {{ $t('misc.id') }} | +{{ $t('misc.key') }} | +{{ $t('misc.value') }} | +{{ $t('misc.source') }} | +{{ $t('misc.page') }} | +{{ $t('misc.system') }} | ++ |
|---|---|---|---|---|---|---|
| {{ $t('common.loading') }} | +||||||
| {{ item.id }} | +{{ item.key }} | +{{ item.value }} | +{{ sourceCodeFor(item.source_id) }} | +{{ item.page_number || '-' }} | +{{ systemCodeFor(item.game_system_id, item.game_system) || '-' }} | ++ |
| {{ item.id }} | +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ |||||
| {{ $t('skillimprovement.id') }} | +{{ $t('skillimprovement.level') }} | +{{ $t('skillimprovement.te') }} | +{{ $t('skillimprovement.category') }} | +{{ $t('skillimprovement.difficulty') }} | ++ |
|---|---|---|---|---|---|
| {{ $t('common.loading') }} | +|||||
| {{ cost.id }} | +{{ cost.current_level }} | +{{ cost.te_required }} | +{{ displayCategory(cost) }} | +{{ displayDifficulty(cost) }} | ++ |
| {{ cost.id }} | +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ ||||