diff --git a/internal/db/audit.go b/internal/db/audit.go index 98b6817..e6fff1f 100644 --- a/internal/db/audit.go +++ b/internal/db/audit.go @@ -7,15 +7,18 @@ import ( ) type AuditMeta struct { - r *http.Request - u *User + ipAddress string + userAgent string + u *User } -func NewAudit(r *http.Request, u *User) *AuditMeta { - if u == nil { - u = CurrentUser(r.Context()) - } - return &AuditMeta{r, u} +func NewAudit(ipAdd, agent string, user *User) *AuditMeta { + return &AuditMeta{ipAdd, agent, user} +} + +func NewAuditFromRequest(r *http.Request) *AuditMeta { + u := CurrentUser(r.Context()) + return &AuditMeta{r.RemoteAddr, r.UserAgent(), u} } // AuditInfo contains metadata for audit logging @@ -45,7 +48,10 @@ func extractTableName[T any]() string { if bunTag != "" { // Parse tag: "table:seasons,alias:s" -> "seasons" for part := range strings.SplitSeq(bunTag, ",") { - part, _ := strings.CutPrefix(part, "table:") + part, match := strings.CutPrefix(part, "table:") + if match { + return part + } return part } } @@ -56,6 +62,38 @@ func extractTableName[T any]() string { return strings.ToLower(t.Name()) + "s" } +// extractTableName gets the bun table alias from a model type using reflection +// Example: Season with `bun:"table:seasons,alias:s"` returns "s" +func extractTableAlias[T any]() string { + var model T + t := reflect.TypeOf(model) + + // Handle pointer types + if t.Kind() == reflect.Pointer { + t = t.Elem() + } + + // Look for bun.BaseModel field with table tag + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.Type.Name() == "BaseModel" { + bunTag := field.Tag.Get("bun") + if bunTag != "" { + // Parse tag: "table:seasons,alias:s" -> "seasons" + for part := range strings.SplitSeq(bunTag, ",") { + part, match := strings.CutPrefix(part, "alias:") + if match { + return part + } + } + } + } + } + + // Fallback: use struct name in lowercase + "s" + return strings.ToLower(t.Name()) + "s" +} + // extractResourceType converts a table name to singular resource type // Example: "seasons" -> "season", "users" -> "user" func extractResourceType(tableName string) string { diff --git a/internal/db/auditlogger.go b/internal/db/auditlogger.go index 988d4d6..02a2831 100644 --- a/internal/db/auditlogger.go +++ b/internal/db/auditlogger.go @@ -49,9 +49,6 @@ func log( if meta.u == nil { return errors.New("user cannot be nil for audit logging") } - if meta.r == nil { - return errors.New("request cannot be nil for audit logging") - } // Convert resourceID to string var resourceIDStr *string @@ -70,18 +67,14 @@ func log( detailsJSON = jsonBytes } - // Extract IP and User-Agent from request - ipAddress := meta.r.RemoteAddr - userAgent := meta.r.UserAgent() - log := &AuditLog{ UserID: meta.u.ID, Action: info.Action, ResourceType: info.ResourceType, ResourceID: resourceIDStr, Details: detailsJSON, - IPAddress: ipAddress, - UserAgent: userAgent, + IPAddress: meta.ipAddress, + UserAgent: meta.userAgent, Result: result, ErrorMessage: errorMessage, CreatedAt: time.Now().Unix(), diff --git a/internal/db/getbyfield.go b/internal/db/getbyfield.go index 7a86944..61cdfe8 100644 --- a/internal/db/getbyfield.go +++ b/internal/db/getbyfield.go @@ -37,6 +37,10 @@ func (g *fieldgetter[T]) Get(ctx context.Context) (*T, error) { return g.get(ctx) } +func (g *fieldgetter[T]) String() string { + return g.q.String() +} + func (g *fieldgetter[T]) Relation(name string, apply ...func(*bun.SelectQuery) *bun.SelectQuery) *fieldgetter[T] { g.q = g.q.Relation(name, apply...) return g @@ -66,5 +70,6 @@ func GetByID[T any]( tx bun.Tx, id int, ) *fieldgetter[T] { - return GetByField[T](tx, "id", id) + prefix := extractTableAlias[T]() + return GetByField[T](tx, prefix+".id", id) } diff --git a/internal/db/migrations/20260216211155_players.go b/internal/db/migrations/20260216211155_players.go new file mode 100644 index 0000000..21a1a14 --- /dev/null +++ b/internal/db/migrations/20260216211155_players.go @@ -0,0 +1,37 @@ +package migrations + +import ( + "context" + + "git.haelnorr.com/h/oslstats/internal/db" + "github.com/uptrace/bun" +) + +func init() { + Migrations.MustRegister( + // UP migration + func(ctx context.Context, conn *bun.DB) error { + // Add your migration code here + _, err := conn.NewCreateTable(). + Model((*db.Player)(nil)). + IfNotExists(). + Exec(ctx) + if err != nil { + return err + } + return nil + }, + // DOWN migration + func(ctx context.Context, conn *bun.DB) error { + // Add your rollback code here + _, err := conn.NewDropTable(). + Model((*db.Player)(nil)). + IfExists(). + Exec(ctx) + if err != nil { + return err + } + return nil + }, + ) +} diff --git a/internal/db/player.go b/internal/db/player.go new file mode 100644 index 0000000..16ed46c --- /dev/null +++ b/internal/db/player.go @@ -0,0 +1,54 @@ +package db + +import ( + "context" + + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +type Player struct { + bun.BaseModel `bun:"table:players,alias:p"` + + ID int `bun:"id,pk,autoincrement" json:"id"` + SlapID *string `bun:"slap_id,unique" json:"slap_id"` + DiscordID string `bun:"discord_id,unique,notnull" json:"discord_id"` + UserID *int `bun:"user_id,unique" json:"user_id"` + + User *User `bun:"rel:belongs-to,join:user_id=id" json:"-"` +} + +func NewPlayer(ctx context.Context, tx bun.Tx, discordID string, audit *AuditMeta) (*Player, error) { + player := &Player{DiscordID: discordID} + user, err := GetUserByDiscordID(ctx, tx, discordID) + if err != nil && !IsBadRequest(err) { + return nil, errors.Wrap(err, "GetUserByDiscordID") + } + if user != nil { + player.UserID = &user.ID + } + err = Insert(tx, player). + WithAudit(audit, nil).Exec(ctx) + if err != nil { + return nil, errors.Wrap(err, "Insert") + } + return player, nil +} + +func GetPlayer(ctx context.Context, tx bun.Tx, playerID int) (*Player, error) { + return GetByID[Player](tx, playerID).Relation("User").Get(ctx) +} + +func UpdatePlayerSlapID(ctx context.Context, tx bun.Tx, playerID int, slapID string, audit *AuditMeta) error { + player, err := GetPlayer(ctx, tx, playerID) + if err != nil { + return errors.Wrap(err, "GetPlayer") + } + player.SlapID = &slapID + err = UpdateByID(tx, player.ID, player).Column("slap_id"). + WithAudit(audit, nil).Exec(ctx) + if err != nil { + return errors.Wrap(err, "UpdateByID") + } + return nil +} diff --git a/internal/db/setup.go b/internal/db/setup.go index e5b39e4..a26769b 100644 --- a/internal/db/setup.go +++ b/internal/db/setup.go @@ -33,6 +33,7 @@ func (db *DB) RegisterModels() []any { (*Permission)(nil), (*AuditLog)(nil), (*Fixture)(nil), + (*Player)(nil), } db.RegisterModel(models...) return models diff --git a/internal/db/user.go b/internal/db/user.go index 0a5bb23..0ab5240 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -20,7 +20,8 @@ type User struct { CreatedAt int64 `bun:"created_at" json:"created_at"` DiscordID string `bun:"discord_id,unique" json:"discord_id"` - Roles []*Role `bun:"m2m:user_roles,join:User=Role" json:"-"` + Roles []*Role `bun:"m2m:user_roles,join:User=Role" json:"-"` + Player *Player `bun:"rel:has-one,join:id=user_id"` } func (u *User) GetID() int { diff --git a/internal/handlers/admin_roles.go b/internal/handlers/admin_roles.go index 3394253..759a340 100644 --- a/internal/handlers/admin_roles.go +++ b/internal/handlers/admin_roles.go @@ -84,7 +84,7 @@ func AdminRoleCreate(s *hws.Server, conn *db.DB) http.Handler { CreatedAt: time.Now().Unix(), } - err := db.CreateRole(ctx, tx, newRole, db.NewAudit(r, nil)) + err := db.CreateRole(ctx, tx, newRole, db.NewAuditFromRequest(r)) if err != nil { return false, errors.Wrap(err, "db.CreateRole") } @@ -196,7 +196,7 @@ func AdminRoleDelete(s *hws.Server, conn *db.DB) http.Handler { } // Delete the role with audit logging - err = db.DeleteRole(ctx, tx, roleID, db.NewAudit(r, nil)) + err = db.DeleteRole(ctx, tx, roleID, db.NewAuditFromRequest(r)) if err != nil { if db.IsBadRequest(err) { respond.NotFound(w, err) @@ -320,7 +320,7 @@ func AdminRolePermissionsUpdate(s *hws.Server, conn *db.DB) http.Handler { } return false, errors.Wrap(err, "db.GetRoleByID") } - err = role.UpdatePermissions(ctx, tx, permissionIDs, db.NewAudit(r, nil)) + err = role.UpdatePermissions(ctx, tx, permissionIDs, db.NewAuditFromRequest(r)) if err != nil { return false, errors.Wrap(err, "role.UpdatePermissions") } diff --git a/internal/handlers/fixtures.go b/internal/handlers/fixtures.go index 4c01d92..f65c4cb 100644 --- a/internal/handlers/fixtures.go +++ b/internal/handlers/fixtures.go @@ -36,7 +36,7 @@ func GenerateFixtures( var league *db.League var fixtures []*db.Fixture if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { - _, err := db.NewRound(ctx, tx, seasonShortName, leagueShortName, round, db.NewAudit(r, nil)) + _, err := db.NewRound(ctx, tx, seasonShortName, leagueShortName, round, db.NewAuditFromRequest(r)) if err != nil { if db.IsBadRequest(err) { respond.BadRequest(w, errors.Wrap(err, "db.NewRound")) @@ -98,7 +98,7 @@ func UpdateFixtures( notify.Warn(s, w, r, "Invalid game weeks", "A game week is missing or has no games", nil) return false, nil } - err = db.UpdateFixtureGameWeeks(ctx, tx, fixtures, db.NewAudit(r, nil)) + err = db.UpdateFixtureGameWeeks(ctx, tx, fixtures, db.NewAuditFromRequest(r)) if err != nil { if db.IsBadRequest(err) { respond.BadRequest(w, errors.Wrap(err, "db.UpdateFixtureGameWeeks")) @@ -125,7 +125,7 @@ func DeleteFixture( return } if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { - err := db.DeleteFixture(ctx, tx, fixtureID, db.NewAudit(r, nil)) + err := db.DeleteFixture(ctx, tx, fixtureID, db.NewAuditFromRequest(r)) if err != nil { if db.IsBadRequest(err) { respond.NotFound(w, errors.Wrap(err, "db.DeleteFixture")) diff --git a/internal/handlers/leagues_new.go b/internal/handlers/leagues_new.go index f033286..6379651 100644 --- a/internal/handlers/leagues_new.go +++ b/internal/handlers/leagues_new.go @@ -61,7 +61,7 @@ func NewLeagueSubmit( if !nameUnique || !shortNameUnique { return true, nil } - league, err = db.NewLeague(ctx, tx, name, shortname, description, db.NewAudit(r, nil)) + league, err = db.NewLeague(ctx, tx, name, shortname, description, db.NewAuditFromRequest(r)) if err != nil { return false, errors.Wrap(err, "db.NewLeague") } diff --git a/internal/handlers/register.go b/internal/handlers/register.go index b4f7a28..f2b834b 100644 --- a/internal/handlers/register.go +++ b/internal/handlers/register.go @@ -64,7 +64,7 @@ func Register( if !unique { return true, nil } - user, err = db.CreateUser(ctx, tx, username, details.DiscordUser, db.NewAudit(r, nil)) + user, err = db.CreateUser(ctx, tx, username, details.DiscordUser, db.NewAuditFromRequest(r)) if err != nil { return false, errors.Wrap(err, "db.CreateUser") } diff --git a/internal/handlers/season_edit.go b/internal/handlers/season_edit.go index 42f3e92..3155ff9 100644 --- a/internal/handlers/season_edit.go +++ b/internal/handlers/season_edit.go @@ -86,7 +86,7 @@ func SeasonEditSubmit( } return false, errors.Wrap(err, "db.GetSeason") } - err = season.Update(ctx, tx, version, start, end, finalsStart, finalsEnd, db.NewAudit(r, nil)) + err = season.Update(ctx, tx, version, start, end, finalsStart, finalsEnd, db.NewAuditFromRequest(r)) if err != nil { return false, errors.Wrap(err, "season.Update") } diff --git a/internal/handlers/season_league_add_team.go b/internal/handlers/season_league_add_team.go index 9c4abf7..88ead10 100644 --- a/internal/handlers/season_league_add_team.go +++ b/internal/handlers/season_league_add_team.go @@ -18,8 +18,8 @@ func SeasonLeagueAddTeam( conn *db.DB, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - seasonStr := r.PathValue("season_short_name") - leagueStr := r.PathValue("league_short_name") + seasonShortName := r.PathValue("season_short_name") + leagueShortName := r.PathValue("league_short_name") getter, ok := validation.ParseFormOrNotify(s, w, r) if !ok { @@ -36,7 +36,7 @@ func SeasonLeagueAddTeam( if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { var err error - team, season, league, err = db.NewTeamParticipation(ctx, tx, seasonStr, leagueStr, teamID, db.NewAudit(r, nil)) + team, season, league, err = db.NewTeamParticipation(ctx, tx, seasonShortName, leagueShortName, teamID, db.NewAuditFromRequest(r)) if err != nil { if db.IsBadRequest(err) { w.WriteHeader(http.StatusBadRequest) diff --git a/internal/handlers/season_league_fixtures.go b/internal/handlers/season_league_fixtures.go index f60420b..417545b 100644 --- a/internal/handlers/season_league_fixtures.go +++ b/internal/handlers/season_league_fixtures.go @@ -92,7 +92,7 @@ func SeasonLeagueDeleteFixtures( var league *db.League var fixtures []*db.Fixture if !conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { - err := db.DeleteAllFixtures(ctx, tx, seasonShortName, leagueShortName, db.NewAudit(r, nil)) + err := db.DeleteAllFixtures(ctx, tx, seasonShortName, leagueShortName, db.NewAuditFromRequest(r)) if err != nil { if db.IsBadRequest(err) { respond.BadRequest(w, errors.Wrap(err, "db.DeleteAllFixtures")) diff --git a/internal/handlers/season_leagues.go b/internal/handlers/season_leagues.go index 963730d..3361427 100644 --- a/internal/handlers/season_leagues.go +++ b/internal/handlers/season_leagues.go @@ -19,13 +19,13 @@ func SeasonAddLeague( conn *db.DB, ) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - seasonStr := r.PathValue("season_short_name") - leagueStr := r.PathValue("league_short_name") + seasonShortName := r.PathValue("season_short_name") + leagueShortName := r.PathValue("league_short_name") var season *db.Season var allLeagues []*db.League if ok := conn.WithNotifyTx(s, w, r, func(ctx context.Context, tx bun.Tx) (bool, error) { - err := db.NewSeasonLeague(ctx, tx, seasonStr, leagueStr, db.NewAudit(r, nil)) + err := db.NewSeasonLeague(ctx, tx, seasonShortName, leagueShortName, db.NewAuditFromRequest(r)) if err != nil { if db.IsBadRequest(err) { respond.BadRequest(w, err) @@ -35,7 +35,7 @@ func SeasonAddLeague( } // Reload season with updated leagues - season, err = db.GetSeason(ctx, tx, seasonStr) + season, err = db.GetSeason(ctx, tx, seasonShortName) if err != nil { return false, errors.Wrap(err, "db.GetSeason") } @@ -75,7 +75,7 @@ func SeasonRemoveLeague( } return false, errors.Wrap(err, "db.GetSeason") } - err = season.RemoveLeague(ctx, tx, leagueStr, db.NewAudit(r, nil)) + err = season.RemoveLeague(ctx, tx, leagueStr, db.NewAuditFromRequest(r)) if err != nil { if db.IsBadRequest(err) { respond.BadRequest(w, err) diff --git a/internal/handlers/seasons_new.go b/internal/handlers/seasons_new.go index 547e11a..dee53ab 100644 --- a/internal/handlers/seasons_new.go +++ b/internal/handlers/seasons_new.go @@ -66,7 +66,7 @@ func NewSeasonSubmit( if !nameUnique || !shortNameUnique { return true, nil } - season, err = db.NewSeason(ctx, tx, name, version, shortname, start, db.NewAudit(r, nil)) + season, err = db.NewSeason(ctx, tx, name, version, shortname, start, db.NewAuditFromRequest(r)) if err != nil { return false, errors.Wrap(err, "db.NewSeason") } diff --git a/internal/handlers/teams_new.go b/internal/handlers/teams_new.go index 442fe2f..629406c 100644 --- a/internal/handlers/teams_new.go +++ b/internal/handlers/teams_new.go @@ -71,7 +71,7 @@ func NewTeamSubmit( if !nameUnique || !shortNameComboUnique { return true, nil } - _, err = db.NewTeam(ctx, tx, name, shortName, altShortName, color, db.NewAudit(r, nil)) + _, err = db.NewTeam(ctx, tx, name, shortName, altShortName, color, db.NewAuditFromRequest(r)) if err != nil { return false, errors.Wrap(err, "db.NewTeam") }