aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.gitignore24
-rw-r--r--README.md9
-rw-r--r--api/dto/favourites.go34
-rw-r--r--api/dto/response.go21
-rw-r--r--api/dto/schedule.go10
-rw-r--r--api/dto/users.go20
-rw-r--r--api/handlers/favourites.go112
-rw-r--r--api/handlers/schedule.go25
-rw-r--r--api/handlers/users.go112
-rw-r--r--api/handlers/util.go28
-rw-r--r--api/middleware/auth.go46
-rw-r--r--api/router.go67
-rw-r--r--go.mod40
-rw-r--r--go.sum87
-rw-r--r--internal/config.go33
-rw-r--r--main.go59
-rw-r--r--pkg/database/database.go45
-rw-r--r--pkg/database/migrations/0001_initial.sql17
-rw-r--r--pkg/database/query/favourites.sql19
-rw-r--r--pkg/database/query/users.sql23
-rw-r--r--pkg/database/sqlc/db.go32
-rw-r--r--pkg/database/sqlc/favourites.sql.go134
-rw-r--r--pkg/database/sqlc/models.go22
-rw-r--r--pkg/database/sqlc/users.sql.go90
-rw-r--r--pkg/favourites/service.go96
-rw-r--r--pkg/schedule/service.go146
-rw-r--r--pkg/user/service.go121
-rw-r--r--sqlc.yaml11
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
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..2842eba
--- /dev/null
+++ b/go.mod
@@ -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
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..5708689
--- /dev/null
+++ b/go.sum
@@ -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
+}
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..ae0ef9d
--- /dev/null
+++ b/main.go
@@ -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