diff options
Diffstat (limited to 'pkg')
| -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 |
11 files changed, 745 insertions, 0 deletions
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 +} |
