diff --git a/cmd/oslstats/httpserver.go b/cmd/oslstats/httpserver.go index bd3b743..d1dbf24 100644 --- a/cmd/oslstats/httpserver.go +++ b/cmd/oslstats/httpserver.go @@ -15,7 +15,7 @@ import ( "git.haelnorr.com/h/oslstats/internal/store" ) -func setupHttpServer( +func setupHTTPServer( staticFS *fs.FS, cfg *config.Config, logger *hlog.Logger, diff --git a/cmd/oslstats/routes.go b/cmd/oslstats/routes.go index 11979cb..90a4932 100644 --- a/cmd/oslstats/routes.go +++ b/cmd/oslstats/routes.go @@ -72,6 +72,16 @@ func addRoutes( Method: hws.MethodPOST, Handler: handlers.SeasonsList(s, conn), }, + { + Path: "/seasons/new", + Methods: []hws.Method{hws.MethodGET, hws.MethodPOST}, + Handler: handlers.NewSeason(s, conn), + }, + { + Path: "/seasons/{season_short_name}", + Method: hws.MethodGET, + Handler: handlers.SeasonPage(s, conn), + }, } htmxRoutes := []hws.Route{ @@ -80,6 +90,16 @@ func addRoutes( Method: hws.MethodPOST, Handler: handlers.IsUsernameUnique(s, conn, cfg, store), }, + { + Path: "/htmx/isseasonnameunique", + Method: hws.MethodPOST, + Handler: handlers.IsSeasonNameUnique(s, conn), + }, + { + Path: "/htmx/isseasonshortnameunique", + Method: hws.MethodPOST, + Handler: handlers.IsSeasonShortNameUnique(s, conn), + }, } wsRoutes := []hws.Route{ diff --git a/cmd/oslstats/run.go b/cmd/oslstats/run.go index acb46f4..90babaf 100644 --- a/cmd/oslstats/run.go +++ b/cmd/oslstats/run.go @@ -29,7 +29,6 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error { if err != nil { return errors.Wrap(err, "setupDBConn") } - defer closedb() // Setup embedded files logger.Debug().Msg("Getting embedded files") @@ -50,7 +49,7 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error { } logger.Debug().Msg("Setting up HTTP server") - httpServer, err := setupHttpServer(&staticFS, cfg, logger, bun, store, discordAPI) + httpServer, err := setupHTTPServer(&staticFS, cfg, logger, bun, store, discordAPI) if err != nil { return errors.Wrap(err, "setupHttpServer") } @@ -72,7 +71,11 @@ func run(ctx context.Context, logger *hlog.Logger, cfg *config.Config) error { logger.Info().Msg("Shut down requested, waiting 60 seconds...") err := httpServer.Shutdown(shutdownCtx) if err != nil { - logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "httpServer.Shutdown"))).Msg("Graceful shutdown failed") + logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "httpServer.Shutdown"))).Msg("Error during HTTP server shutdown") + } + err = closedb() + if err != nil { + logger.Error().Err(err).Str("stacktrace", fmt.Sprintf("%+v", errors.Wrap(err, "closedb"))).Msg("Error during database close") } }) wg.Wait() diff --git a/go.sum b/go.sum index a0d75de..cd54517 100644 --- a/go.sum +++ b/go.sum @@ -6,12 +6,8 @@ git.haelnorr.com/h/golib/ezconf v0.1.1 h1:4euTSDb9jvuQQkVq+x5gHoYPYyUZPWxoOSlWCI git.haelnorr.com/h/golib/ezconf v0.1.1/go.mod h1:rETDcjpcEyyeBgCiZSU617wc0XycwZSC5+IAOtXmwP8= git.haelnorr.com/h/golib/hlog v0.10.4 h1:vpCsV/OddjIYx8F48U66WxojjmhEbeLGQAOBG4ViSRQ= git.haelnorr.com/h/golib/hlog v0.10.4/go.mod h1:+wJ8vecQY/JITTXKmI3JfkHiUGyMs7N6wooj2wuWZbc= -git.haelnorr.com/h/golib/hws v0.4.3 h1:rpqe0Dcbm3b5XZ/Bfy0LUhph6RR7+bmANrSA/W81l0A= -git.haelnorr.com/h/golib/hws v0.4.3/go.mod h1:UqB83p9lGjidDkk0pWRqxxOFrCkg8t+9J6uGtBOjNLo= git.haelnorr.com/h/golib/hws v0.4.4 h1:tV9UjZ4q96UlOdJKsC7b3kDV+bpQYqKVPQuaV1n3U3k= git.haelnorr.com/h/golib/hws v0.4.4/go.mod h1:dxAbbGGNzqLXhZXwgt091QsvsPBdrS+1YsNQNldNVoM= -git.haelnorr.com/h/golib/hwsauth v0.5.3 h1:Vgw8khDQZJRCc3m7z9QlbL9CYPyFB9JXUC3+omKRZPc= -git.haelnorr.com/h/golib/hwsauth v0.5.3/go.mod h1:NOonrVU/lX8lzuV77eDEiTwBjn7RrzYVcSdXUJWeHmQ= git.haelnorr.com/h/golib/hwsauth v0.5.4 h1:nuaiVpJHHXgKVRPoQSE/v3CJHSkivViK5h3SVhEcbbM= git.haelnorr.com/h/golib/hwsauth v0.5.4/go.mod h1:eIjRPeGycvxRWERkxCoRVMEEhHuUdiPDvjpzzZOhQ0w= git.haelnorr.com/h/golib/jwt v0.10.1 h1:1Adxt9H3Y4fWFvFjWpvg/vSFhbgCMDMxgiE3m7KvDMI= diff --git a/internal/db/doc.go b/internal/db/doc.go new file mode 100644 index 0000000..3557935 --- /dev/null +++ b/internal/db/doc.go @@ -0,0 +1,2 @@ +// Package db is an internal package for all the database models and related methods +package db diff --git a/internal/db/season.go b/internal/db/season.go index fab36ae..a976b3d 100644 --- a/internal/db/season.go +++ b/internal/db/season.go @@ -3,6 +3,8 @@ package db import ( "context" "database/sql" + "strings" + "time" "github.com/pkg/errors" "github.com/uptrace/bun" @@ -11,9 +13,13 @@ import ( type Season struct { bun.BaseModel `bun:"table:seasons,alias:s"` - ID int `bun:"id,pk,autoincrement"` - Name string `bun:"name,unique"` - ShortName string `bun:"short_name,unique"` + ID int `bun:"id,pk,autoincrement"` + Name string `bun:"name,unique"` + ShortName string `bun:"short_name,unique"` + StartDate time.Time `bun:"start_date,notnull"` + EndDate bun.NullTime `bun:"end_date"` + FinalsStartDate bun.NullTime `bun:"finals_start_date"` + FinalsEndDate bun.NullTime `bun:"finals_end_date"` } type SeasonList struct { @@ -22,7 +28,7 @@ type SeasonList struct { PageOpts PageOpts } -func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string) (*Season, error) { +func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string, start time.Time) (*Season, error) { if name == "" { return nil, errors.New("name cannot be empty") } @@ -31,7 +37,8 @@ func NewSeason(ctx context.Context, tx bun.Tx, name, shortname string) (*Season, } season := &Season{ Name: name, - ShortName: shortname, + ShortName: strings.ToUpper(shortname), + StartDate: start.Truncate(time.Hour * 24), } _, err := tx.NewInsert(). Model(season). @@ -81,3 +88,38 @@ func ListSeasons(ctx context.Context, tx bun.Tx, pageOpts *PageOpts) (*SeasonLis } return sl, nil } + +func GetSeason(ctx context.Context, tx bun.Tx, shortname string) (*Season, error) { + var season *Season + err := tx.NewSelect(). + Model(season). + Where("short_name = ?", strings.ToUpper(shortname)). + Limit(1). + Scan(ctx) + if err != nil && err != sql.ErrNoRows { + return nil, errors.Wrap(err, "tx.NewSelect") + } + return season, nil +} + +func IsSeasonNameUnique(ctx context.Context, tx bun.Tx, name string) (bool, error) { + count, err := tx.NewSelect(). + Model((*Season)(nil)). + Where("name = ?", name). + Count(ctx) + if err != nil { + return false, errors.Wrap(err, "tx.NewSelect") + } + return count == 0, nil +} + +func IsSeasonShortNameUnique(ctx context.Context, tx bun.Tx, shortname string) (bool, error) { + count, err := tx.NewSelect(). + Model((*Season)(nil)). + Where("short_name = ?", shortname). + Count(ctx) + if err != nil { + return false, errors.Wrap(err, "tx.NewSelect") + } + return count == 0, nil +} diff --git a/internal/db/user.go b/internal/db/user.go index 8546c1e..5af8092 100644 --- a/internal/db/user.go +++ b/internal/db/user.go @@ -26,20 +26,6 @@ func (user *User) GetID() int { return user.ID } -// Change the user's username -func (user *User) ChangeUsername(ctx context.Context, tx bun.Tx, newUsername string) error { - _, err := tx.NewUpdate(). - Model(user). - Set("username = ?", newUsername). - Where("id = ?", user.ID). - Exec(ctx) - if err != nil { - return errors.Wrap(err, "tx.NewUpdate") - } - user.Username = newUsername - return nil -} - // CreateUser creates a new user with the given username and password func CreateUser(ctx context.Context, tx bun.Tx, username string, discorduser *discordgo.User) (*User, error) { if discorduser == nil { diff --git a/internal/handlers/doc.go b/internal/handlers/doc.go new file mode 100644 index 0000000..af7fe59 --- /dev/null +++ b/internal/handlers/doc.go @@ -0,0 +1,2 @@ +// Package handlers contains all the functions for handling http requests and serving content +package handlers diff --git a/internal/handlers/errors.go b/internal/handlers/errors.go index 1a3f1a9..a647607 100644 --- a/internal/handlers/errors.go +++ b/internal/handlers/errors.go @@ -6,6 +6,7 @@ import ( "net/http" "git.haelnorr.com/h/golib/hws" + "github.com/a-h/templ" "github.com/pkg/errors" ) @@ -166,3 +167,10 @@ func parseErrorDetails(details string) (int, string) { return errDetails.Code, errDetails.Stacktrace } + +func renderSafely(page templ.Component, s *hws.Server, r *http.Request, w http.ResponseWriter) { + err := page.Render(r.Context(), w) + if err != nil { + throwInternalServiceError(s, w, r, "Failed to render page", errors.Wrap(err, "page.")) + } +} diff --git a/internal/handlers/index.go b/internal/handlers/index.go index 25e7ac5..ec91c5f 100644 --- a/internal/handlers/index.go +++ b/internal/handlers/index.go @@ -8,15 +8,15 @@ import ( "git.haelnorr.com/h/golib/hws" ) -// Handles responses to the / path. Also serves a 404 Page for paths that +// Index handles responses to the / path. Also serves a 404 Page for paths that // don't have explicit handlers -func Index(server *hws.Server) http.Handler { +func Index(s *hws.Server) http.Handler { return http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { - throwNotFound(server, w, r, r.URL.Path) + throwNotFound(s, w, r, r.URL.Path) } - page.Index().Render(r.Context(), w) + renderSafely(page.Index(), s, r, w) }, ) } diff --git a/internal/handlers/newseason.go b/internal/handlers/newseason.go new file mode 100644 index 0000000..f019568 --- /dev/null +++ b/internal/handlers/newseason.go @@ -0,0 +1,105 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/view/page" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func NewSeason( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + renderSafely(page.NewSeason(), s, r, w) + return + } + }) +} + +func NewSeasonSubmit( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + }) +} + +func IsSeasonNameUnique( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "conn.BeginTx")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + name := r.FormValue("name") + unique, err := db.IsSeasonNameUnique(ctx, tx, name) + if err != nil { + err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonNameUnique")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + if !unique { + w.WriteHeader(http.StatusConflict) + return + } + }) +} + +func IsSeasonShortNameUnique( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + w.WriteHeader(http.StatusBadRequest) + return + } + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "conn.BeginTx")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + shortname := r.FormValue("short_name") + unique, err := db.IsSeasonShortNameUnique(ctx, tx, shortname) + if err != nil { + err = notifyInternalServiceError(s, r, "Database error", errors.Wrap(err, "db.IsSeasonShortNameUnique")) + if err != nil { + throwInternalServiceError(s, w, r, "Error notifying client", err) + } + return + } + if !unique { + w.WriteHeader(http.StatusConflict) + return + } + }) +} diff --git a/internal/handlers/register.go b/internal/handlers/register.go index 352d17f..c0fa59a 100644 --- a/internal/handlers/register.go +++ b/internal/handlers/register.go @@ -76,7 +76,7 @@ func Register( method := r.Method if method == "GET" { tx.Commit() - page.Register(details.DiscordUser.Username).Render(r.Context(), w) + renderSafely(page.Register(details.DiscordUser.Username), s, r, w) return } if method == "POST" { diff --git a/internal/handlers/season.go b/internal/handlers/season.go new file mode 100644 index 0000000..bfd7c93 --- /dev/null +++ b/internal/handlers/season.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "context" + "net/http" + "time" + + "git.haelnorr.com/h/golib/hws" + "git.haelnorr.com/h/oslstats/internal/db" + "git.haelnorr.com/h/oslstats/internal/view/page" + "github.com/pkg/errors" + "github.com/uptrace/bun" +) + +func SeasonPage( + s *hws.Server, + conn *bun.DB, +) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) + defer cancel() + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "conn.BeginTx")) + return + } + defer tx.Rollback() + seasonStr := r.PathValue("season_short_name") + season, err := db.GetSeason(ctx, tx, seasonStr) + if err != nil { + throwInternalServiceError(s, w, r, "Database error", errors.Wrap(err, "db.GetSeason")) + return + } + tx.Commit() + if season == nil { + throwNotFound(s, w, r, r.URL.Path) + return + } + renderSafely(page.SeasonPage(season), s, r, w) + }) +} diff --git a/internal/handlers/seasons.go b/internal/handlers/seasons.go index 930db6d..92840ae 100644 --- a/internal/handlers/seasons.go +++ b/internal/handlers/seasons.go @@ -58,7 +58,7 @@ func SeasonsPage( return } tx.Commit() - page.SeasonsPage(seasons).Render(r.Context(), w) + renderSafely(page.SeasonsList(seasons), s, r, w) }) } @@ -123,6 +123,6 @@ func SeasonsList( tx.Commit() // Return only the list component (hx-push-url handles URL update client-side) - page.SeasonsList(seasons).Render(r.Context(), w) + renderSafely(page.SeasonsList(seasons), s, r, w) }) } diff --git a/internal/handlers/static.go b/internal/handlers/static.go index 3176129..3f4d2ff 100644 --- a/internal/handlers/static.go +++ b/internal/handlers/static.go @@ -1,13 +1,14 @@ package handlers import ( - "git.haelnorr.com/h/golib/hws" "net/http" "path/filepath" "strings" + + "git.haelnorr.com/h/golib/hws" ) -// Handles requests for static files, without allowing access to the +// StaticFS handles requests for static files, without allowing access to the // directory viewer and returning 404 if an exact file is not found func StaticFS(staticFS *http.FileSystem, server *hws.Server) http.Handler { // Create the file server once, not on every request diff --git a/internal/handlers/test.go b/internal/handlers/test.go index c05714e..78d33ed 100644 --- a/internal/handlers/test.go +++ b/internal/handlers/test.go @@ -9,7 +9,7 @@ import ( "git.haelnorr.com/h/oslstats/internal/view/page" ) -// Handles responses to the / path. Also serves a 404 Page for paths that +// NotifyTester handles responses to the / path. Also serves a 404 Page for paths that // don't have explicit handlers func NotifyTester(s *hws.Server) http.Handler { return http.HandlerFunc( @@ -22,9 +22,9 @@ func NotifyTester(s *hws.Server) http.Handler { // Error: testErr, // }) // page.Render(r.Context(), w) - page.Test().Render(r.Context(), w) + renderSafely(page.Test(), s, r, w) } else { - r.ParseForm() + _ = r.ParseForm() // target := r.Form.Get("target") title := r.Form.Get("title") level := r.Form.Get("type") diff --git a/internal/view/component/form/new_season.templ b/internal/view/component/form/new_season.templ new file mode 100644 index 0000000..9bda1b3 --- /dev/null +++ b/internal/view/component/form/new_season.templ @@ -0,0 +1,4 @@ +package form + +templ NewSeason() { +} diff --git a/internal/view/page/new_season.templ b/internal/view/page/new_season.templ new file mode 100644 index 0000000..ac3eb8b --- /dev/null +++ b/internal/view/page/new_season.templ @@ -0,0 +1,9 @@ +package page + +import "git.haelnorr.com/h/oslstats/internal/view/component/form" +import "git.haelnorr.com/h/oslstats/internal/view/layout" + +templ NewSeason() { + @layout.Global("New Season") + @form.NewSeason() +} diff --git a/internal/view/page/season.templ b/internal/view/page/season.templ new file mode 100644 index 0000000..80e10a9 --- /dev/null +++ b/internal/view/page/season.templ @@ -0,0 +1,202 @@ +package page + +import "git.haelnorr.com/h/oslstats/internal/db" +import "git.haelnorr.com/h/oslstats/internal/view/layout" +import "time" +import "strconv" + +templ SeasonPage(season *db.Season) { + @layout.Global(season.Name) { +