diff options
| author | Leonardo Bishop <me@leonardobishop.com> | 2025-01-17 13:21:24 +0000 |
|---|---|---|
| committer | Leonardo Bishop <me@leonardobishop.com> | 2025-01-17 13:21:24 +0000 |
| commit | c00b690bd6f600554a1404e692bd9e4373325d27 (patch) | |
| tree | 4488b625e1c24af52fced6f60ac1b3ddff1383bc | |
Initial commit
| -rw-r--r-- | .gitignore | 24 | ||||
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | api/dto/favourites.go | 34 | ||||
| -rw-r--r-- | api/dto/response.go | 21 | ||||
| -rw-r--r-- | api/dto/schedule.go | 10 | ||||
| -rw-r--r-- | api/dto/users.go | 20 | ||||
| -rw-r--r-- | api/handlers/favourites.go | 112 | ||||
| -rw-r--r-- | api/handlers/schedule.go | 25 | ||||
| -rw-r--r-- | api/handlers/users.go | 112 | ||||
| -rw-r--r-- | api/handlers/util.go | 28 | ||||
| -rw-r--r-- | api/middleware/auth.go | 46 | ||||
| -rw-r--r-- | api/router.go | 67 | ||||
| -rw-r--r-- | go.mod | 40 | ||||
| -rw-r--r-- | go.sum | 87 | ||||
| -rw-r--r-- | internal/config.go | 33 | ||||
| -rw-r--r-- | main.go | 59 | ||||
| -rw-r--r-- | pkg/database/database.go | 45 | ||||
| -rw-r--r-- | pkg/database/migrations/0001_initial.sql | 17 | ||||
| -rw-r--r-- | pkg/database/query/favourites.sql | 19 | ||||
| -rw-r--r-- | pkg/database/query/users.sql | 23 | ||||
| -rw-r--r-- | pkg/database/sqlc/db.go | 32 | ||||
| -rw-r--r-- | pkg/database/sqlc/favourites.sql.go | 134 | ||||
| -rw-r--r-- | pkg/database/sqlc/models.go | 22 | ||||
| -rw-r--r-- | pkg/database/sqlc/users.sql.go | 90 | ||||
| -rw-r--r-- | pkg/favourites/service.go | 96 | ||||
| -rw-r--r-- | pkg/schedule/service.go | 146 | ||||
| -rw-r--r-- | pkg/user/service.go | 121 | ||||
| -rw-r--r-- | sqlc.yaml | 11 |
28 files changed, 1483 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c5059c7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work +go.work.sum + +# env file +.env + +config.yaml diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b6287b --- /dev/null +++ b/README.md @@ -0,0 +1,9 @@ +# confplanner + +The back office program for confplanner. + +Responsibilities: + +* Favourites persistence +* Retrieving and updating the schedule +* Synchronising users calendars diff --git a/api/dto/favourites.go b/api/dto/favourites.go new file mode 100644 index 0000000..0f8021f --- /dev/null +++ b/api/dto/favourites.go @@ -0,0 +1,34 @@ +package dto + +import "github.com/LMBishop/confplanner/pkg/database/sqlc" + +type CreateFavouritesRequest struct { + GUID *string `json:"eventGuid"` + ID *int32 `json:"eventId"` +} + +type CreateFavouritesResponse struct { + ID int32 `json:"id"` +} + +type GetFavouritesResponse struct { + ID int32 `json:"id"` + GUID *string `json:"eventGuid,omitempty"` + EventID *int32 `json:"eventId,omitempty"` +} + +func (dst *GetFavouritesResponse) Scan(src sqlc.Favourite) { + dst.ID = src.ID + if src.EventGuid.Valid { + strGuid := src.EventGuid.String() + dst.GUID = &strGuid + } + if src.EventID.Valid { + dst.EventID = &src.EventID.Int32 + } +} + +type DeleteFavouritesRequest struct { + GUID *string `json:"eventGuid"` + ID *int32 `json:"eventId"` +} diff --git a/api/dto/response.go b/api/dto/response.go new file mode 100644 index 0000000..43f98bd --- /dev/null +++ b/api/dto/response.go @@ -0,0 +1,21 @@ +package dto + +import "fmt" + +type OkResponse struct { + Code int `json:"code"` + Data interface{} `json:"data,omitempty"` +} + +type ErrorResponse struct { + Code int `json:"code"` + Message string `json:"message"` +} + +func (r *OkResponse) Error() string { + return fmt.Sprintf("HTTP status %d", r.Code) +} + +func (r *ErrorResponse) Error() string { + return fmt.Sprintf("HTTP status %d: %s", r.Code, r.Message) +} diff --git a/api/dto/schedule.go b/api/dto/schedule.go new file mode 100644 index 0000000..0d652aa --- /dev/null +++ b/api/dto/schedule.go @@ -0,0 +1,10 @@ +package dto + +import ( + "time" +) + +type GetScheduleResponse struct { + Schedule interface{} `json:"schedule"` + LastUpdated time.Time `json:"lastUpdated"` +} diff --git a/api/dto/users.go b/api/dto/users.go new file mode 100644 index 0000000..685fa07 --- /dev/null +++ b/api/dto/users.go @@ -0,0 +1,20 @@ +package dto + +type RegisterRequest struct { + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` +} + +type RegisterResponse struct { + ID int32 `json:"id"` +} + +type LoginRequest struct { + Username string `json:"username" validate:"required"` + Password string `json:"password" validate:"required"` +} + +type LoginResponse struct { + ID int32 `json:"id"` + Username string `json:"username"` +} diff --git a/api/handlers/favourites.go b/api/handlers/favourites.go new file mode 100644 index 0000000..862d366 --- /dev/null +++ b/api/handlers/favourites.go @@ -0,0 +1,112 @@ +package handlers + +import ( + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/favourites" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5/pgtype" +) + +func CreateFavourite(service favourites.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + var request dto.CreateFavouritesRequest + if err := readBody(c, &request); err != nil { + return err + } + + if request.GUID == nil && request.ID == nil { + return &dto.ErrorResponse{ + Code: fiber.StatusBadRequest, + Message: "One of event GUID or event ID must be specified", + } + } + + uid := c.Locals("uid").(int32) + var uuid pgtype.UUID + if request.GUID != nil { + if err := uuid.Scan(*request.GUID); err != nil { + return &dto.ErrorResponse{ + Code: fiber.StatusBadRequest, + Message: "Bad event GUID", + } + } + } + + createdFavourite, err := service.CreateFavouriteForUser(uid, uuid, request.ID) + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: fiber.StatusCreated, + Data: &dto.CreateFavouritesResponse{ + ID: createdFavourite.ID, + }, + } + } +} + +func GetFavourites(service favourites.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + uid := c.Locals("uid").(int32) + + favourites, err := service.GetFavouritesForUser(uid) + if err != nil { + return err + } + + favouritesResponse := make([]dto.GetFavouritesResponse, 0) + for _, favourite := range *favourites { + var favouriteResponse dto.GetFavouritesResponse + favouriteResponse.Scan(favourite) + + favouritesResponse = append(favouritesResponse, favouriteResponse) + } + + return &dto.OkResponse{ + Code: fiber.StatusOK, + Data: favouritesResponse, + } + } +} + +func DeleteFavourite(service favourites.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + var request dto.DeleteFavouritesRequest + if err := readBody(c, &request); err != nil { + return err + } + + if request.GUID == nil && request.ID == nil { + return &dto.ErrorResponse{ + Code: fiber.StatusBadRequest, + Message: "One of event GUID or event ID must be specified", + } + } + + uid := c.Locals("uid").(int32) + var err error + var uuid pgtype.UUID + if err := uuid.Scan(*request.GUID); err != nil { + return &dto.ErrorResponse{ + Code: fiber.StatusBadRequest, + Message: "Bad event GUID", + } + } + + err = service.DeleteFavouriteForUserByEventDetails(uid, uuid, request.ID) + if err != nil { + if err == favourites.ErrNotFound { + return &dto.ErrorResponse{ + Code: fiber.StatusNotFound, + Message: "Favourite not found", + } + } + return err + } + + return &dto.OkResponse{ + Code: fiber.StatusOK, + } + } +} diff --git a/api/handlers/schedule.go b/api/handlers/schedule.go new file mode 100644 index 0000000..fd3a183 --- /dev/null +++ b/api/handlers/schedule.go @@ -0,0 +1,25 @@ +package handlers + +import ( + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/schedule" + "github.com/gofiber/fiber/v2" + "github.com/golang-cz/nilslice" +) + +func GetSchedule(service schedule.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + schedule, lastUpdated, err := service.GetSchedule() + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: fiber.StatusOK, + Data: &dto.GetScheduleResponse{ + Schedule: nilslice.Initialize(*schedule), + LastUpdated: *lastUpdated, + }, + } + } +} diff --git a/api/handlers/users.go b/api/handlers/users.go new file mode 100644 index 0000000..deda9ca --- /dev/null +++ b/api/handlers/users.go @@ -0,0 +1,112 @@ +package handlers + +import ( + "errors" + "time" + + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/user" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/session" +) + +func Register(service user.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + var request dto.RegisterRequest + if err := readBody(c, &request); err != nil { + return err + } + + createdUser, err := service.CreateUser(request.Username, request.Password) + if err != nil { + if errors.Is(err, user.ErrUserExists) { + return &dto.ErrorResponse{ + Code: fiber.StatusConflict, + Message: "User with that username already exists", + } + } else if errors.Is(err, user.ErrNotAcceptingRegistrations) { + return &dto.ErrorResponse{ + Code: fiber.StatusForbidden, + Message: "This service is not currently accepting registrations", + } + } + + return err + } + + return &dto.OkResponse{ + Code: fiber.StatusCreated, + Data: &dto.RegisterResponse{ + ID: createdUser.ID, + }, + } + } +} + +func Login(service user.Service, store *session.Store) fiber.Handler { + return func(c *fiber.Ctx) error { + var request dto.LoginRequest + if err := readBody(c, &request); err != nil { + return err + } + + user, err := service.Authenticate(request.Username, request.Password) + if err != nil { + return err + } + + if user == nil { + return &dto.ErrorResponse{ + Code: fiber.StatusBadRequest, + Message: "Username and password combination not found", + } + } + + s, err := store.Get(c) + if err != nil { + return err + } + + if s.Fresh() { + uid := user.ID + sid := s.ID() + + s.Set("uid", uid) + s.Set("sid", sid) + s.Set("ip", c.Context().RemoteIP().String()) + s.Set("login", time.Unix(time.Now().Unix(), 0).UTC().String()) + s.Set("ua", string(c.Request().Header.UserAgent())) + + err = s.Save() + if err != nil { + return err + } + } + + return &dto.OkResponse{ + Code: fiber.StatusOK, + Data: &dto.LoginResponse{ + ID: user.ID, + Username: user.Username, + }, + } + } +} + +func Logout(store *session.Store) fiber.Handler { + return func(c *fiber.Ctx) error { + s, err := store.Get(c) + if err != nil { + return err + } + + err = s.Destroy() + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: fiber.StatusNoContent, + } + } +} diff --git a/api/handlers/util.go b/api/handlers/util.go new file mode 100644 index 0000000..b0cf344 --- /dev/null +++ b/api/handlers/util.go @@ -0,0 +1,28 @@ +package handlers + +import ( + "fmt" + + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +var validate = validator.New(validator.WithRequiredStructEnabled()) + +func readBody(c *fiber.Ctx, request interface{}) error { + if err := c.BodyParser(request); err != nil { + return &fiber.Error{ + Code: fiber.StatusBadRequest, + Message: fmt.Errorf("Invalid request (%w)", err).Error(), + } + } + + if err := validate.Struct(request); err != nil { + return &fiber.Error{ + Code: fiber.StatusBadRequest, + Message: err.Error(), + } + } + + return nil +} diff --git a/api/middleware/auth.go b/api/middleware/auth.go new file mode 100644 index 0000000..611276a --- /dev/null +++ b/api/middleware/auth.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "errors" + + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/user" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/session" +) + +func RequireAuthenticated(service user.Service, store *session.Store) fiber.Handler { + return func(c *fiber.Ctx) error { + s, err := store.Get(c) + if err != nil { + return err + } + + if s.Fresh() || len(s.Keys()) == 0 { + return &dto.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Message: "Unauthorized", + } + } + + uid := s.Get("uid").(int32) + + fetchedUser, err := service.GetUserByID(uid) + if err != nil { + if errors.Is(err, user.ErrUserNotFound) { + s.Destroy() + return &dto.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Message: "Invalid session", + } + } + + return err + } + + c.Locals("uid", uid) + c.Locals("username", fetchedUser.Username) + + return c.Next() + } +} diff --git a/api/router.go b/api/router.go new file mode 100644 index 0000000..82a29b1 --- /dev/null +++ b/api/router.go @@ -0,0 +1,67 @@ +package api + +import ( + "errors" + "log/slog" + "time" + + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/api/handlers" + "github.com/LMBishop/confplanner/api/middleware" + "github.com/LMBishop/confplanner/pkg/favourites" + "github.com/LMBishop/confplanner/pkg/schedule" + "github.com/LMBishop/confplanner/pkg/user" + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/session" +) + +type ApiServices struct { + UserService user.Service + FavouritesService favourites.Service + ScheduleService schedule.Service +} + +func NewServer(apiServices ApiServices) *fiber.App { + sessionStore := session.New(session.Config{ + Expiration: 24 * time.Hour, + KeyLookup: "cookie:confplanner_session", + CookieSameSite: "None", + }) + + app := fiber.New(fiber.Config{ + ErrorHandler: func(ctx *fiber.Ctx, err error) error { + var ok *dto.OkResponse + if errors.As(err, &ok) { + return ctx.Status(ok.Code).JSON(ok) + } + + var e *dto.ErrorResponse + if errors.As(err, &e) { + return ctx.Status(e.Code).JSON(e) + } + + slog.Error("fiber runtime error", "error", err, "URL", ctx.OriginalURL()) + return ctx.Status(500).JSON(dto.ErrorResponse{ + Code: 500, + Message: "Internal Server Error", + }) + }, + AppName: "confplanner", + }) + + // app.Use(cors.New()) + + requireAuthenticated := middleware.RequireAuthenticated(apiServices.UserService, sessionStore) + + app.Post("/register", handlers.Register(apiServices.UserService)) + app.Post("/login", handlers.Login(apiServices.UserService, sessionStore)) + app.Post("/logout", requireAuthenticated, handlers.Logout(sessionStore)) + + app.Get("/favourites", requireAuthenticated, handlers.GetFavourites(apiServices.FavouritesService)) + app.Post("/favourites", requireAuthenticated, handlers.CreateFavourite(apiServices.FavouritesService)) + app.Delete("/favourites", requireAuthenticated, handlers.DeleteFavourite(apiServices.FavouritesService)) + + app.Get("/schedule", requireAuthenticated, handlers.GetSchedule(apiServices.ScheduleService)) + + return app +} @@ -0,0 +1,40 @@ +module github.com/LMBishop/confplanner + +go 1.23.4 + +require ( + github.com/andybalholm/brotli v1.1.1 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.23.0 // indirect + github.com/gofiber/fiber/v2 v2.52.6 // indirect + github.com/golang-cz/nilslice v0.0.0-20240305001642-646f70fbdbf7 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx v3.6.2+incompatible // indirect + github.com/jackc/pgx/v5 v5.7.2 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mfridman/interpolate v0.0.2 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pressly/goose/v3 v3.24.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + golang.org/x/crypto v0.32.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sync v0.10.0 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/text v0.21.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) @@ -0,0 +1,87 @@ +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= +github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.23.0 h1:/PwmTwZhS0dPkav3cdK9kV1FsAmrL8sThn8IHr/sO+o= +github.com/go-playground/validator/v10 v10.23.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/gofiber/fiber/v2 v2.52.6 h1:Rfp+ILPiYSvvVuIPvxrBns+HJp8qGLDnLJawAu27XVI= +github.com/gofiber/fiber/v2 v2.52.6/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-cz/nilslice v0.0.0-20240305001642-646f70fbdbf7 h1:VJnCioFIl+oq9XDpadU0bg3w2ItReDcipwUWeeFE/hA= +github.com/golang-cz/nilslice v0.0.0-20240305001642-646f70fbdbf7/go.mod h1:zKbg8dCJWqvE0zHOhHXPHFpqpABjoK3MDzWo+Pi1O7k= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o= +github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= +github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pressly/goose/v3 v3.24.1 h1:bZmxRco2uy5uu5Ng1MMVEfYsFlrMJI+e/VMXHQ3C4LY= +github.com/pressly/goose/v3 v3.24.1/go.mod h1:rEWreU9uVtt0DHCyLzF9gRcWiiTF/V+528DV+4DORug= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= +github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config.go b/internal/config.go new file mode 100644 index 0000000..9a1bc5f --- /dev/null +++ b/internal/config.go @@ -0,0 +1,33 @@ +package config + +import ( + "os" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Server struct { + Host string `yaml:"host"` + Port string `yaml:"port"` + } `yaml:"server"` + Database struct { + ConnString string `yaml:"connString"` + } `yaml:"database"` + Conference struct { + ScheduleURL string `yaml:"scheduleURL"` + } `yaml:"conference"` + AcceptRegistrations bool `yaml:"acceptRegistrations"` +} + +func ReadConfig(configPath string, dst *Config) error { + config, err := os.ReadFile(configPath) + if err != nil { + return err + } + + if err := yaml.Unmarshal(config, dst); err != nil { + return err + } + return nil +} @@ -0,0 +1,59 @@ +package main + +import ( + "fmt" + "log/slog" + "os" + + "github.com/LMBishop/confplanner/api" + config "github.com/LMBishop/confplanner/internal" + "github.com/LMBishop/confplanner/pkg/database" + "github.com/LMBishop/confplanner/pkg/favourites" + "github.com/LMBishop/confplanner/pkg/schedule" + "github.com/LMBishop/confplanner/pkg/user" +) + +func main() { + if err := run(); err != nil { + slog.Error("Unhandled error", "error", err) + os.Exit(1) + } +} + +func run() error { + c := &config.Config{} + err := config.ReadConfig("config.yaml", c) + if err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + pool, err := database.Connect(c.Database.ConnString) + if err != nil { + return fmt.Errorf("failed to connect to database: %w", err) + } + + if err := database.Migrate(pool); err != nil { + return fmt.Errorf("database migration failed: %w", err) + } + + userService := user.NewService(pool, c.AcceptRegistrations) + favouritesService := favourites.NewService(pool) + scheduleService, err := schedule.NewService(c.Conference.ScheduleURL) + if err != nil { + return fmt.Errorf("failed to create schedule service: %w", err) + } + + app := api.NewServer(api.ApiServices{ + UserService: userService, + FavouritesService: favouritesService, + ScheduleService: scheduleService, + }) + + slog.Info("Server is listening", "host", c.Server.Host, "port", c.Server.Port) + + if err := app.Listen(fmt.Sprintf("%s:%s", c.Server.Host, c.Server.Port)); err != nil { + return fmt.Errorf("failed to start server: %w", err) + } + + return nil +} diff --git a/pkg/database/database.go b/pkg/database/database.go new file mode 100644 index 0000000..5570c95 --- /dev/null +++ b/pkg/database/database.go @@ -0,0 +1,45 @@ +package database + +import ( + "context" + "embed" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/pressly/goose/v3" +) + +//go:embed migrations/*.sql +var embedMigrations embed.FS + +func Connect(connString string) (*pgxpool.Pool, error) { + ctx := context.Background() + + config, err := pgxpool.ParseConfig(connString) + if err != nil { + return nil, err + } + + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + return nil, err + } + + return pool, nil +} + +func Migrate(pool *pgxpool.Pool) error { + goose.SetBaseFS(embedMigrations) + + if err := goose.SetDialect("postgres"); err != nil { + return fmt.Errorf("could not set dialect: %w", err) + } + + db := stdlib.OpenDBFromPool(pool) + if err := goose.Up(db, "migrations"); err != nil { + return fmt.Errorf("could not apply migrations: %w", err) + } + + return nil +} diff --git a/pkg/database/migrations/0001_initial.sql b/pkg/database/migrations/0001_initial.sql new file mode 100644 index 0000000..eea0a73 --- /dev/null +++ b/pkg/database/migrations/0001_initial.sql @@ -0,0 +1,17 @@ +-- +goose Up +CREATE TABLE users ( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + username varchar(20) UNIQUE NOT NULL, + password char(60) NOT NULL +); + +CREATE TABLE favourites ( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id int NOT NULL, + event_guid uuid, + event_id int, + UNIQUE(user_id, event_guid, event_id), + CONSTRAINT chk_favourites CHECK (event_guid IS NOT NULL OR event_id IS NOT NULL), + FOREIGN KEY (user_id) REFERENCES users(id) +); + diff --git a/pkg/database/query/favourites.sql b/pkg/database/query/favourites.sql new file mode 100644 index 0000000..0661daa --- /dev/null +++ b/pkg/database/query/favourites.sql @@ -0,0 +1,19 @@ +-- name: GetFavouritesForUser :many +SELECT * FROM favourites +WHERE user_id = $1; + +-- name: CreateFavourite :one +INSERT INTO favourites ( + user_id, event_guid, event_id +) VALUES ( + $1, $2, $3 +) +RETURNING *; + +-- name: DeleteFavourite :exec +DELETE FROM favourites +WHERE id = $1; + +-- name: DeleteFavouriteByEventDetails :execrows +DELETE FROM favourites +WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3;
\ No newline at end of file diff --git a/pkg/database/query/users.sql b/pkg/database/query/users.sql new file mode 100644 index 0000000..c70ebbb --- /dev/null +++ b/pkg/database/query/users.sql @@ -0,0 +1,23 @@ +-- name: GetUserByID :one +SELECT * FROM users +WHERE id = $1 LIMIT 1; + +-- name: GetUserByName :one +SELECT * FROM users +WHERE username = $1 LIMIT 1; + +-- name: ListUsers :many +SELECT * FROM users +ORDER BY username; + +-- name: CreateUser :one +INSERT INTO users ( + username, password +) VALUES ( + $1, $2 +) +RETURNING *; + +-- name: DeleteUser :exec +DELETE FROM users +WHERE id = $1; diff --git a/pkg/database/sqlc/db.go b/pkg/database/sqlc/db.go new file mode 100644 index 0000000..b931bc5 --- /dev/null +++ b/pkg/database/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/pkg/database/sqlc/favourites.sql.go b/pkg/database/sqlc/favourites.sql.go new file mode 100644 index 0000000..3bf7c06 --- /dev/null +++ b/pkg/database/sqlc/favourites.sql.go @@ -0,0 +1,134 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: favourites.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createFavourite = `-- name: CreateFavourite :one +INSERT INTO favourites ( + user_id, event_guid, event_id +) VALUES ( + $1, $2, $3 +) +RETURNING id, user_id, event_guid, event_id +` + +type CreateFavouriteParams struct { + UserID int32 `json:"user_id"` + EventGuid pgtype.UUID `json:"event_guid"` + EventID pgtype.Int4 `json:"event_id"` +} + +func (q *Queries) CreateFavourite(ctx context.Context, arg CreateFavouriteParams) (Favourite, error) { + row := q.db.QueryRow(ctx, createFavourite, arg.UserID, arg.EventGuid, arg.EventID) + var i Favourite + err := row.Scan( + &i.ID, + &i.UserID, + &i.EventGuid, + &i.EventID, + ) + return i, err +} + +const deleteFavourite = `-- name: DeleteFavourite :exec +DELETE FROM favourites +WHERE id = $1 +` + +func (q *Queries) DeleteFavourite(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, deleteFavourite, id) + return err +} + +const deleteFavouriteByEventDetails = `-- name: DeleteFavouriteByEventDetails :execrows +DELETE FROM favourites +WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3 +` + +type DeleteFavouriteByEventDetailsParams struct { + EventGuid pgtype.UUID `json:"event_guid"` + EventID pgtype.Int4 `json:"event_id"` + UserID int32 `json:"user_id"` +} + +func (q *Queries) DeleteFavouriteByEventDetails(ctx context.Context, arg DeleteFavouriteByEventDetailsParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteFavouriteByEventDetails, arg.EventGuid, arg.EventID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const deleteFavouriteByEventGuid = `-- name: DeleteFavouriteByEventGuid :execrows +DELETE FROM favourites +WHERE event_guid = $1 AND user_id = $2 +` + +type DeleteFavouriteByEventGuidParams struct { + EventGuid pgtype.UUID `json:"event_guid"` + UserID int32 `json:"user_id"` +} + +func (q *Queries) DeleteFavouriteByEventGuid(ctx context.Context, arg DeleteFavouriteByEventGuidParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteFavouriteByEventGuid, arg.EventGuid, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const deleteFavouriteByEventId = `-- name: DeleteFavouriteByEventId :execrows +DELETE FROM favourites +WHERE event_id = $1 AND user_id = $2 +` + +type DeleteFavouriteByEventIdParams struct { + EventID pgtype.Int4 `json:"event_id"` + UserID int32 `json:"user_id"` +} + +func (q *Queries) DeleteFavouriteByEventId(ctx context.Context, arg DeleteFavouriteByEventIdParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteFavouriteByEventId, arg.EventID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const getFavouritesForUser = `-- name: GetFavouritesForUser :many +SELECT id, user_id, event_guid, event_id FROM favourites +WHERE user_id = $1 +` + +func (q *Queries) GetFavouritesForUser(ctx context.Context, userID int32) ([]Favourite, error) { + rows, err := q.db.Query(ctx, getFavouritesForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Favourite + for rows.Next() { + var i Favourite + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.EventGuid, + &i.EventID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/pkg/database/sqlc/models.go b/pkg/database/sqlc/models.go new file mode 100644 index 0000000..09208aa --- /dev/null +++ b/pkg/database/sqlc/models.go @@ -0,0 +1,22 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 + +package sqlc + +import ( + "github.com/jackc/pgx/v5/pgtype" +) + +type Favourite struct { + ID int32 `json:"id"` + UserID int32 `json:"user_id"` + EventGuid pgtype.UUID `json:"event_guid"` + EventID pgtype.Int4 `json:"event_id"` +} + +type User struct { + ID int32 `json:"id"` + Username string `json:"username"` + Password string `json:"password"` +} diff --git a/pkg/database/sqlc/users.sql.go b/pkg/database/sqlc/users.sql.go new file mode 100644 index 0000000..dfd2c2f --- /dev/null +++ b/pkg/database/sqlc/users.sql.go @@ -0,0 +1,90 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: users.sql + +package sqlc + +import ( + "context" +) + +const createUser = `-- name: CreateUser :one +INSERT INTO users ( + username, password +) VALUES ( + $1, $2 +) +RETURNING id, username, password +` + +type CreateUserParams struct { + Username string `json:"username"` + Password string `json:"password"` +} + +func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) { + row := q.db.QueryRow(ctx, createUser, arg.Username, arg.Password) + var i User + err := row.Scan(&i.ID, &i.Username, &i.Password) + return i, err +} + +const deleteUser = `-- name: DeleteUser :exec +DELETE FROM users +WHERE id = $1 +` + +func (q *Queries) DeleteUser(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, deleteUser, id) + return err +} + +const getUserByID = `-- name: GetUserByID :one +SELECT id, username, password FROM users +WHERE id = $1 LIMIT 1 +` + +func (q *Queries) GetUserByID(ctx context.Context, id int32) (User, error) { + row := q.db.QueryRow(ctx, getUserByID, id) + var i User + err := row.Scan(&i.ID, &i.Username, &i.Password) + return i, err +} + +const getUserByName = `-- name: GetUserByName :one +SELECT id, username, password FROM users +WHERE username = $1 LIMIT 1 +` + +func (q *Queries) GetUserByName(ctx context.Context, username string) (User, error) { + row := q.db.QueryRow(ctx, getUserByName, username) + var i User + err := row.Scan(&i.ID, &i.Username, &i.Password) + return i, err +} + +const listUsers = `-- name: ListUsers :many +SELECT id, username, password FROM users +ORDER BY username +` + +func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { + rows, err := q.db.Query(ctx, listUsers) + if err != nil { + return nil, err + } + defer rows.Close() + var items []User + for rows.Next() { + var i User + if err := rows.Scan(&i.ID, &i.Username, &i.Password); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/pkg/favourites/service.go b/pkg/favourites/service.go new file mode 100644 index 0000000..cba249e --- /dev/null +++ b/pkg/favourites/service.go @@ -0,0 +1,96 @@ +package favourites + +import ( + "context" + "errors" + "fmt" + + "github.com/LMBishop/confplanner/pkg/database/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Service interface { + GetFavouritesForUser(id int32) (*[]sqlc.Favourite, error) + CreateFavouriteForUser(id int32, eventGuid pgtype.UUID, eventId *int32) (*sqlc.Favourite, error) + DeleteFavouriteForUserByEventDetails(id int32, eventGuid pgtype.UUID, eventId *int32) error +} + +var ( + ErrImproperType = errors.New("improper type") + ErrNotFound = errors.New("not found") +) + +type service struct { + pool *pgxpool.Pool +} + +func NewService(pool *pgxpool.Pool) Service { + return &service{ + pool: pool, + } +} + +func (s *service) CreateFavouriteForUser(id int32, eventGuid pgtype.UUID, eventId *int32) (*sqlc.Favourite, error) { + queries := sqlc.New(s.pool) + + var pgEventId pgtype.Int4 + if eventId != nil { + pgEventId = pgtype.Int4{ + Int32: *eventId, + Valid: true, + } + } + + favourite, err := queries.CreateFavourite(context.Background(), sqlc.CreateFavouriteParams{ + UserID: id, + EventGuid: eventGuid, + EventID: pgEventId, + }) + if err != nil { + return nil, fmt.Errorf("could not create favourite: %w", err) + } + + return &favourite, nil +} + +func (s *service) GetFavouritesForUser(id int32) (*[]sqlc.Favourite, error) { + queries := sqlc.New(s.pool) + + favourites, err := queries.GetFavouritesForUser(context.Background(), id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + empty := make([]sqlc.Favourite, 0) + return &empty, nil + } + return nil, fmt.Errorf("could not fetch user: %w", err) + } + + return &favourites, nil +} + +func (s *service) DeleteFavouriteForUserByEventDetails(id int32, eventGuid pgtype.UUID, eventId *int32) error { + queries := sqlc.New(s.pool) + + var pgEventId pgtype.Int4 + if eventId != nil { + pgEventId = pgtype.Int4{ + Int32: *eventId, + Valid: true, + } + } + rowsAffected, err := queries.DeleteFavouriteByEventDetails(context.Background(), sqlc.DeleteFavouriteByEventDetailsParams{ + EventGuid: eventGuid, + EventID: pgEventId, + UserID: id, + }) + if err != nil { + return fmt.Errorf("could not delete favourite: %w", err) + } + if rowsAffected == 0 { + return ErrNotFound + } + + return nil +} diff --git a/pkg/schedule/service.go b/pkg/schedule/service.go new file mode 100644 index 0000000..70587ef --- /dev/null +++ b/pkg/schedule/service.go @@ -0,0 +1,146 @@ +package schedule + +import ( + "bufio" + "encoding/xml" + "fmt" + "net/http" + "sync" + "time" +) + +type Service interface { + GetSchedule() (*Schedule, *time.Time, error) +} + +type Schedule struct { + XMLName xml.Name `json:"-" xml:"schedule"` + Conference Conference `json:"conference" xml:"conference"` + Tracks []Track `json:"tracks" xml:"tracks>track"` + Days []Day `json:"days" xml:"day"` +} + +type Conference struct { + Title string `json:"title" xml:"title"` + Venue string `json:"venue" xml:"venue"` + City string `json:"city" xml:"city"` + Start string `json:"start" xml:"start"` + End string `json:"end" xml:"end"` + Days int `json:"days" xml:"days"` + DayChange string `json:"dayChange" xml:"day_change"` + TimeslotDuration string `json:"timeslotDuration" xml:"timeslot_duration"` + BaseURL string `json:"baseUrl" xml:"base_url"` + TimeZoneName string `json:"timeZoneName" xml:"time_zone_name"` +} + +type Track struct { + Name string `json:"name" xml:",chardata"` +} + +type Day struct { + Date string `json:"date" xml:"date,attr"` + Start string `json:"start" xml:"start"` + End string `json:"end" xml:"end"` + Rooms []Room `json:"rooms" xml:"room"` +} + +type Room struct { + Name string `json:"name" xml:"name,attr"` + Events []Event `json:"events" xml:"event"` +} + +type Event struct { + ID int `json:"id" xml:"id,attr"` + GUID string `json:"guid" xml:"guid,attr"` + Date string `json:"date" xml:"date"` + Start string `json:"start" xml:"start"` + Duration string `json:"duration" xml:"duration"` + Room string `json:"room" xml:"room"` + URL string `json:"url" xml:"url"` + Track string `json:"track" xml:"track"` + Type string `json:"type" xml:"type"` + Title string `json:"title" xml:"title"` + Abstract string `json:"abstract" xml:"abstract"` + Persons []Person `json:"persons" xml:"persons>person"` + Attachments []Attachment `json:"attachments" xml:"attachments>attachment"` + Links []Link `json:"links" xml:"links>link"` +} + +type Person struct { + ID int `json:"id" xml:"id,attr"` + Name string `json:"name" xml:",chardata"` +} + +type Attachment struct { + Type string `json:"string" xml:"id,attr"` + Href string `json:"href" xml:"href,attr"` + Name string `json:"name" xml:",chardata"` +} + +type Link struct { + Href string `json:"href" xml:"href,attr"` + Name string `json:"name" xml:",chardata"` +} + +type service struct { + schedule *Schedule + pentabarfUrl string + lastUpdated time.Time + lock sync.Mutex +} + +func NewService(pentabarfUrl string) (Service, error) { + service := &service{ + pentabarfUrl: pentabarfUrl, + lastUpdated: time.Unix(0, 0), + } + + err := service.updateSchedule() + if err != nil { + return nil, fmt.Errorf("could not read schedule from '%s' (is it a valid pentabarf XML file?): %w", pentabarfUrl, err) + } + return service, nil +} + +func (s *service) GetSchedule() (*Schedule, *time.Time, error) { + if s.hasScheduleExpired() { + err := s.updateSchedule() + if err != nil { + return nil, nil, err + } + } + + return s.schedule, &s.lastUpdated, nil +} + +func (s *service) hasScheduleExpired() bool { + expire := s.lastUpdated.Add(15 * time.Minute) + return time.Now().After(expire) +} + +func (s *service) updateSchedule() error { + s.lock.Lock() + defer s.lock.Unlock() + + if !s.hasScheduleExpired() { + return nil + } + + res, err := http.Get(s.pentabarfUrl) + if err != nil { + return err + } + + reader := bufio.NewReader(res.Body) + + var schedule Schedule + + decoder := xml.NewDecoder(reader) + if err := decoder.Decode(&schedule); err != nil { + return fmt.Errorf("failed to decode XML: %w", err) + } + + s.schedule = &schedule + s.lastUpdated = time.Now() + return nil +} diff --git a/pkg/user/service.go b/pkg/user/service.go new file mode 100644 index 0000000..7784811 --- /dev/null +++ b/pkg/user/service.go @@ -0,0 +1,121 @@ +package user + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/LMBishop/confplanner/pkg/database/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + "golang.org/x/crypto/bcrypt" +) + +type Service interface { + CreateUser(username string, password string) (*sqlc.User, error) + GetUserByName(username string) (*sqlc.User, error) + GetUserByID(id int32) (*sqlc.User, error) + Authenticate(username string, password string) (*sqlc.User, error) +} + +var ( + ErrUserExists = errors.New("user already exists") + ErrUserNotFound = errors.New("user not found") + ErrNotAcceptingRegistrations = errors.New("not currently accepting registrations") +) + +type service struct { + pool *pgxpool.Pool + acceptingRegistrations bool +} + +func NewService(pool *pgxpool.Pool, acceptingRegistrations bool) Service { + return &service{ + pool: pool, + acceptingRegistrations: acceptingRegistrations, + } +} + +func (s *service) CreateUser(username string, password string) (*sqlc.User, error) { + if !s.acceptingRegistrations { + return nil, ErrNotAcceptingRegistrations + } + + queries := sqlc.New(s.pool) + + var passwordBytes = []byte(password) + + hash, err := bcrypt.GenerateFromPassword(passwordBytes, bcrypt.DefaultCost) + if err != nil { + return nil, fmt.Errorf("could not hash password: %w", err) + } + + user, err := queries.CreateUser(context.Background(), sqlc.CreateUserParams{ + Username: strings.ToLower(username), + Password: string(hash), + }) + if err != nil { + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && pgErr.Code == "23505" { + return nil, ErrUserExists + } + return nil, fmt.Errorf("could not create user: %w", err) + } + + return &user, nil +} + +func (s *service) GetUserByName(username string) (*sqlc.User, error) { + queries := sqlc.New(s.pool) + + user, err := queries.GetUserByName(context.Background(), username) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("could not fetch user: %w", err) + } + + return &user, nil +} + +func (s *service) GetUserByID(id int32) (*sqlc.User, error) { + queries := sqlc.New(s.pool) + + user, err := queries.GetUserByID(context.Background(), id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrUserNotFound + } + return nil, fmt.Errorf("could not fetch user: %w", err) + } + + return &user, nil +} + +func (s *service) Authenticate(username string, password string) (*sqlc.User, error) { + random, err := bcrypt.GenerateFromPassword([]byte("00000000"), bcrypt.DefaultCost) + if err != nil { + return nil, err + } + + user, err := s.GetUserByName(username) + if err != nil { + if errors.Is(err, ErrUserNotFound) { + bcrypt.CompareHashAndPassword(random, []byte(password)) + return nil, nil + } + return nil, err + } + + if err = bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(password)); err != nil { + if errors.Is(err, bcrypt.ErrMismatchedHashAndPassword) { + return nil, nil + } + return nil, err + } + + return user, nil +} diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..ea28527 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,11 @@ +version: "2" +sql: + - engine: "postgresql" + queries: "pkg/database/query" + schema: "pkg/database/migrations" + gen: + go: + package: "sqlc" + sql_package: "pgx/v5" + out: "pkg/database/sqlc" + emit_json_tags: true
\ No newline at end of file |
