From ecc6a55aba7bb35fc778e7a53848396b88214151 Mon Sep 17 00:00:00 2001 From: Leonardo Bishop Date: Sat, 23 Aug 2025 22:29:28 +0100 Subject: Add multiple conferences feature --- pkg/conference/model.go | 72 +++++++ pkg/conference/parse.go | 205 ++++++++++++++++++++ pkg/conference/service.go | 218 ++++++++++++++++++++++ pkg/database/migrations/0003_multi_conference.sql | 13 ++ pkg/database/migrations/0004_user_admins.sql | 2 + pkg/database/query/conferences.sql | 21 +++ pkg/database/query/favourites.sql | 10 +- pkg/database/sqlc/conferences.sql.go | 119 ++++++++++++ pkg/database/sqlc/favourites.sql.go | 76 ++++++-- pkg/database/sqlc/models.go | 18 +- pkg/database/sqlc/users.sql.go | 36 +++- pkg/favourites/service.go | 63 ++++--- pkg/ical/service.go | 17 +- pkg/schedule/model.go | 72 ------- pkg/schedule/parse.go | 205 -------------------- pkg/schedule/service.go | 116 ------------ pkg/session/memory.go | 3 +- pkg/session/service.go | 3 +- 18 files changed, 818 insertions(+), 451 deletions(-) create mode 100644 pkg/conference/model.go create mode 100644 pkg/conference/parse.go create mode 100644 pkg/conference/service.go create mode 100644 pkg/database/migrations/0003_multi_conference.sql create mode 100644 pkg/database/migrations/0004_user_admins.sql create mode 100644 pkg/database/query/conferences.sql create mode 100644 pkg/database/sqlc/conferences.sql.go delete mode 100644 pkg/schedule/model.go delete mode 100644 pkg/schedule/parse.go delete mode 100644 pkg/schedule/service.go (limited to 'pkg') diff --git a/pkg/conference/model.go b/pkg/conference/model.go new file mode 100644 index 0000000..343271f --- /dev/null +++ b/pkg/conference/model.go @@ -0,0 +1,72 @@ +package conference + +import "time" + +type Schedule struct { + Conference Conference `json:"conference"` + Tracks []Track `json:"tracks"` + Days []Day `json:"days"` +} + +type Conference struct { + Title string `json:"title"` + Venue string `json:"venue"` + City string `json:"city"` + Start string `json:"start"` + End string `json:"end"` + Days int `json:"days"` + DayChange string `json:"dayChange"` + TimeslotDuration string `json:"timeslotDuration"` + BaseURL string `json:"baseUrl"` + TimeZoneName string `json:"timeZoneName"` +} + +type Track struct { + Name string `json:"name"` +} + +type Day struct { + Date string `json:"date"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + Rooms []Room `json:"rooms"` +} + +type Room struct { + Name string `json:"name"` + Events []Event `json:"events"` +} + +type Event struct { + ID int32 `json:"id"` + GUID string `json:"guid"` + Date string `json:"date"` + Start time.Time `json:"start"` + End time.Time `json:"end"` + Duration int32 `json:"duration"` + Room string `json:"room"` + URL string `json:"url"` + Track string `json:"track"` + Type string `json:"type"` + Title string `json:"title"` + Abstract string `json:"abstract"` + Persons []Person `json:"persons"` + Attachments []Attachment `json:"attachments"` + Links []Link `json:"links"` +} + +type Person struct { + ID int `json:"id"` + Name string `json:"name"` +} + +type Attachment struct { + Type string `json:"string"` + Href string `json:"href"` + Name string `json:"name"` +} + +type Link struct { + Href string `json:"href"` + Name string `json:"name"` +} diff --git a/pkg/conference/parse.go b/pkg/conference/parse.go new file mode 100644 index 0000000..b7f9dba --- /dev/null +++ b/pkg/conference/parse.go @@ -0,0 +1,205 @@ +package conference + +import ( + "encoding/xml" + "fmt" + "time" +) + +type schedule struct { + XMLName xml.Name `xml:"schedule"` + Conference conference `xml:"conference"` + Tracks []track `xml:"tracks>track"` + Days []day `xml:"day"` +} + +type conference struct { + Title string `xml:"title"` + Venue string `xml:"venue"` + City string `xml:"city"` + Start string `xml:"start"` + End string `xml:"end"` + Days int `xml:"days"` + DayChange string `xml:"day_change"` + TimeslotDuration string `xml:"timeslot_duration"` + BaseURL string `xml:"base_url"` + TimeZoneName string `xml:"time_zone_name"` +} + +type track struct { + Name string `xml:",chardata"` +} + +type day struct { + Date string `xml:"date,attr"` + Start string `xml:"start,attr"` + End string `xml:"end,attr"` + Rooms []room `xml:"room"` +} + +type room struct { + Name string `xml:"name,attr"` + Events []event `xml:"event"` +} + +type event struct { + ID int32 `xml:"id,attr"` + GUID string `xml:"guid,attr"` + Date string `xml:"date"` + Start string `xml:"start"` + Duration string `xml:"duration"` + Room string `xml:"room"` + URL string `xml:"url"` + Track string `xml:"track"` + Type string `xml:"type"` + Title string `xml:"title"` + Abstract string `xml:"abstract"` + Persons []person `xml:"persons>person"` + Attachments []attachment `xml:"attachments>attachment"` + Links []link `xml:"links>link"` +} + +type person struct { + ID int `xml:"id,attr"` + Name string `xml:",chardata"` +} + +type attachment struct { + Type string `xml:"id,attr"` + Href string `xml:"href,attr"` + Name string `xml:",chardata"` +} + +type link struct { + Href string `xml:"href,attr"` + Name string `xml:",chardata"` +} + +func (dst *Schedule) Scan(src schedule) error { + dst.Conference.Scan(src.Conference) + + dst.Tracks = make([]Track, len(src.Tracks)) + for i := range src.Tracks { + dst.Tracks[i].Scan(src.Tracks[i]) + } + dst.Days = make([]Day, len(src.Days)) + for i := range src.Days { + if err := dst.Days[i].Scan(src.Days[i]); err != nil { + return fmt.Errorf("failed to scan day: %w", err) + } + } + return nil +} + +func (dst *Conference) Scan(src conference) { + dst.Title = src.Title + dst.Venue = src.Venue + dst.City = src.City + dst.Start = src.Start + dst.End = src.End + dst.Days = src.Days + dst.DayChange = src.DayChange + dst.TimeslotDuration = src.TimeslotDuration + dst.BaseURL = src.BaseURL + dst.TimeZoneName = src.TimeZoneName +} + +func (dst *Track) Scan(src track) { + dst.Name = src.Name +} + +func (dst *Day) Scan(src day) error { + dst.Date = src.Date + + start, err := time.Parse(time.RFC3339, src.Start) + if err != nil { + return fmt.Errorf("failed to parse start time: %w", err) + } + end, err := time.Parse(time.RFC3339, src.End) + if err != nil { + return fmt.Errorf("failed to parse end time: %w", err) + } + + dst.Start = start + dst.End = end + + dst.Rooms = make([]Room, len(src.Rooms)) + for i := range src.Rooms { + dst.Rooms[i].Scan(src.Rooms[i]) + } + return nil +} + +func (dst *Room) Scan(src room) { + dst.Name = src.Name + + dst.Events = make([]Event, len(src.Events)) + for i := range src.Events { + dst.Events[i].Scan(src.Events[i]) + } +} + +func (dst *Event) Scan(src event) error { + dst.ID = src.ID + dst.GUID = src.GUID + dst.Date = src.Date + + duration, err := parseDuration(src.Duration) + if err != nil { + return err + } + start, err := time.Parse(time.RFC3339, src.Date) + if err != nil { + start = time.Unix(0, 0) + } + dst.Start = start + dst.End = start.Add(time.Minute * time.Duration(duration)) + + dst.Room = src.Room + dst.URL = src.URL + dst.Track = src.Track + dst.Type = src.Type + dst.Title = src.Title + dst.Abstract = src.Abstract + + dst.Persons = make([]Person, len(src.Persons)) + for i := range src.Persons { + dst.Persons[i].Scan(src.Persons[i]) + } + + dst.Attachments = make([]Attachment, len(src.Attachments)) + for i := range src.Attachments { + dst.Attachments[i].Scan(src.Attachments[i]) + } + + dst.Links = make([]Link, len(src.Links)) + for i := range src.Links { + dst.Links[i].Scan(src.Links[i]) + } + + return nil +} + +func (dst *Person) Scan(src person) { + dst.ID = src.ID + dst.Name = src.Name +} + +func (dst *Attachment) Scan(src attachment) { + dst.Type = src.Type + dst.Href = src.Href + dst.Name = src.Name +} + +func (dst *Link) Scan(src link) { + dst.Href = src.Href + dst.Name = src.Name +} + +func parseDuration(duration string) (int32, error) { + d, err := time.Parse("15:04", duration) + if err != nil { + return 0, err + } + return int32(d.Minute() + d.Hour()*60), nil +} diff --git a/pkg/conference/service.go b/pkg/conference/service.go new file mode 100644 index 0000000..59c2c8c --- /dev/null +++ b/pkg/conference/service.go @@ -0,0 +1,218 @@ +package conference + +import ( + "bufio" + "context" + "encoding/xml" + "errors" + "fmt" + "net/http" + "sync" + "time" + + "github.com/LMBishop/confplanner/pkg/database/sqlc" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Service interface { + CreateConference(url string) (*sqlc.Conference, error) + DeleteConference(id int32) error + GetConferences() ([]sqlc.Conference, error) + GetSchedule(id int32) (*Schedule, time.Time, error) + GetEventByID(conferenceID, eventID int32) (*Event, error) +} + +type loadedConference struct { + pentabarfUrl string + schedule *Schedule + eventsById map[int32]Event + lastUpdated time.Time + lock sync.RWMutex +} + +var ( + ErrConferenceNotFound = errors.New("conference not found") + ErrScheduleFetch = errors.New("could not fetch schedule") +) + +type service struct { + conferences map[int32]*loadedConference + lock sync.RWMutex + pool *pgxpool.Pool +} + +// TODO: Create a service implementation that persists to DB +// and isn't in memory +func NewService(pool *pgxpool.Pool) (Service, error) { + service := &service{ + pool: pool, + conferences: make(map[int32]*loadedConference), + } + + queries := sqlc.New(pool) + conferences, err := queries.GetConferences(context.Background()) + if err != nil { + return nil, err + } + + for _, conference := range conferences { + c := &loadedConference{ + pentabarfUrl: conference.Url, + lastUpdated: time.Unix(0, 0), + } + service.conferences[conference.ID] = c + } + + return service, nil +} + +func (s *service) CreateConference(url string) (*sqlc.Conference, error) { + s.lock.Lock() + defer s.lock.Unlock() + + c := &loadedConference{ + pentabarfUrl: url, + lastUpdated: time.Unix(0, 0), + } + err := c.updateSchedule() + if err != nil { + return nil, errors.Join(ErrScheduleFetch, err) + } + + queries := sqlc.New(s.pool) + + conference, err := queries.CreateConference(context.Background(), sqlc.CreateConferenceParams{ + Url: url, + Title: pgtype.Text{String: c.schedule.Conference.Title, Valid: true}, + Venue: pgtype.Text{String: c.schedule.Conference.Venue, Valid: true}, + City: pgtype.Text{String: c.schedule.Conference.City, Valid: true}, + }) + if err != nil { + return nil, fmt.Errorf("could not create conference: %w", err) + } + + s.conferences[conference.ID] = c + + return &conference, nil +} + +func (s *service) DeleteConference(id int32) error { + s.lock.Lock() + defer s.lock.Unlock() + + queries := sqlc.New(s.pool) + err := queries.DeleteConference(context.Background(), id) + if err != nil { + return fmt.Errorf("could not delete conference: %w", err) + } + + delete(s.conferences, id) + return nil +} + +func (s *service) GetConferences() ([]sqlc.Conference, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + queries := sqlc.New(s.pool) + return queries.GetConferences(context.Background()) +} + +func (s *service) GetSchedule(id int32) (*Schedule, time.Time, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + c, ok := s.conferences[id] + if !ok { + return nil, time.Time{}, ErrConferenceNotFound + } + + if err := c.updateSchedule(); err != nil { + return nil, time.Time{}, err + } + + c.lock.RLock() + defer c.lock.RUnlock() + + queries := sqlc.New(s.pool) + if _, err := queries.UpdateConferenceDetails(context.Background(), sqlc.UpdateConferenceDetailsParams{ + ID: id, + Title: pgtype.Text{String: c.schedule.Conference.Title, Valid: true}, + Venue: pgtype.Text{String: c.schedule.Conference.Venue, Valid: true}, + City: pgtype.Text{String: c.schedule.Conference.City, Valid: true}, + }); err != nil { + return nil, time.Time{}, fmt.Errorf("failed to update cached conference details: %w", err) + } + + return c.schedule, c.lastUpdated, nil +} + +func (s *service) GetEventByID(conferenceID, eventID int32) (*Event, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + c, ok := s.conferences[conferenceID] + if !ok { + return nil, ErrConferenceNotFound + } + + c.lock.RLock() + defer c.lock.RUnlock() + + event := c.eventsById[eventID] + + return &event, nil +} + +func (c *loadedConference) hasScheduleExpired() bool { + expire := c.lastUpdated.Add(15 * time.Minute) + return time.Now().After(expire) +} + +func (c *loadedConference) updateSchedule() error { + if !c.hasScheduleExpired() { + return nil + } + + if !c.lock.TryLock() { + // don't block if another goroutine is already fetching + return nil + } + defer c.lock.Unlock() + + res, err := http.Get(c.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) + } + + var newSchedule Schedule + err = newSchedule.Scan(schedule) + if err != nil { + return fmt.Errorf("failed to scan schedule: %w", err) + } + + c.schedule = &newSchedule + c.lastUpdated = time.Now() + + c.eventsById = make(map[int32]Event) + + for _, day := range newSchedule.Days { + for _, room := range day.Rooms { + for _, event := range room.Events { + c.eventsById[event.ID] = event + } + } + } + + return nil +} diff --git a/pkg/database/migrations/0003_multi_conference.sql b/pkg/database/migrations/0003_multi_conference.sql new file mode 100644 index 0000000..31a1f58 --- /dev/null +++ b/pkg/database/migrations/0003_multi_conference.sql @@ -0,0 +1,13 @@ +-- +goose Up +CREATE TABLE conferences ( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + url text NOT NULL, + title text, + venue text, + city text +); + +TRUNCATE TABLE favourites CONTINUE IDENTITY; +ALTER TABLE favourites ADD conference_id int NOT NULL REFERENCES conferences(id) ON DELETE CASCADE; +ALTER TABLE favourites DROP CONSTRAINT favourites_user_id_event_guid_event_id_key; +ALTER TABLE favourites ADD UNIQUE(user_id, event_guid, event_id, conference_id); diff --git a/pkg/database/migrations/0004_user_admins.sql b/pkg/database/migrations/0004_user_admins.sql new file mode 100644 index 0000000..5c4efb3 --- /dev/null +++ b/pkg/database/migrations/0004_user_admins.sql @@ -0,0 +1,2 @@ +-- +goose Up +ALTER TABLE users ADD admin boolean NOT NULL DEFAULT false; diff --git a/pkg/database/query/conferences.sql b/pkg/database/query/conferences.sql new file mode 100644 index 0000000..7acac3f --- /dev/null +++ b/pkg/database/query/conferences.sql @@ -0,0 +1,21 @@ +-- name: CreateConference :one +INSERT INTO conferences ( + url, title, venue, city +) VALUES ( + $1, $2, $3, $4 +) +RETURNING *; + +-- name: UpdateConferenceDetails :one +UPDATE conferences SET ( + title, venue, city +) = ($2, $3, $4) +WHERE id = $1 +RETURNING *; + +-- name: GetConferences :many +SELECT * FROM conferences; + +-- name: DeleteConference :exec +DELETE FROM conferences +WHERE id = $1; diff --git a/pkg/database/query/favourites.sql b/pkg/database/query/favourites.sql index 94e914c..a903ac7 100644 --- a/pkg/database/query/favourites.sql +++ b/pkg/database/query/favourites.sql @@ -1,12 +1,16 @@ +-- name: GetFavouritesForUserConference :many +SELECT * FROM favourites +WHERE user_id = $1 AND conference_id = $2; + -- name: GetFavouritesForUser :many SELECT * FROM favourites WHERE user_id = $1; -- name: CreateFavourite :one INSERT INTO favourites ( - user_id, event_guid, event_id + user_id, event_guid, event_id, conference_id ) VALUES ( - $1, $2, $3 + $1, $2, $3, $4 ) RETURNING *; @@ -16,4 +20,4 @@ WHERE id = $1; -- name: DeleteFavouriteByEventDetails :execrows DELETE FROM favourites -WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3; +WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3 AND conference_id = $4; diff --git a/pkg/database/sqlc/conferences.sql.go b/pkg/database/sqlc/conferences.sql.go new file mode 100644 index 0000000..1345185 --- /dev/null +++ b/pkg/database/sqlc/conferences.sql.go @@ -0,0 +1,119 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: conferences.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createConference = `-- name: CreateConference :one +INSERT INTO conferences ( + url, title, venue, city +) VALUES ( + $1, $2, $3, $4 +) +RETURNING id, url, title, venue, city +` + +type CreateConferenceParams struct { + Url string `json:"url"` + Title pgtype.Text `json:"title"` + Venue pgtype.Text `json:"venue"` + City pgtype.Text `json:"city"` +} + +func (q *Queries) CreateConference(ctx context.Context, arg CreateConferenceParams) (Conference, error) { + row := q.db.QueryRow(ctx, createConference, + arg.Url, + arg.Title, + arg.Venue, + arg.City, + ) + var i Conference + err := row.Scan( + &i.ID, + &i.Url, + &i.Title, + &i.Venue, + &i.City, + ) + return i, err +} + +const deleteConference = `-- name: DeleteConference :exec +DELETE FROM conferences +WHERE id = $1 +` + +func (q *Queries) DeleteConference(ctx context.Context, id int32) error { + _, err := q.db.Exec(ctx, deleteConference, id) + return err +} + +const getConferences = `-- name: GetConferences :many +SELECT id, url, title, venue, city FROM conferences +` + +func (q *Queries) GetConferences(ctx context.Context) ([]Conference, error) { + rows, err := q.db.Query(ctx, getConferences) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Conference + for rows.Next() { + var i Conference + if err := rows.Scan( + &i.ID, + &i.Url, + &i.Title, + &i.Venue, + &i.City, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateConferenceDetails = `-- name: UpdateConferenceDetails :one +UPDATE conferences SET ( + title, venue, city +) = ($2, $3, $4) +WHERE id = $1 +RETURNING id, url, title, venue, city +` + +type UpdateConferenceDetailsParams struct { + ID int32 `json:"id"` + Title pgtype.Text `json:"title"` + Venue pgtype.Text `json:"venue"` + City pgtype.Text `json:"city"` +} + +func (q *Queries) UpdateConferenceDetails(ctx context.Context, arg UpdateConferenceDetailsParams) (Conference, error) { + row := q.db.QueryRow(ctx, updateConferenceDetails, + arg.ID, + arg.Title, + arg.Venue, + arg.City, + ) + var i Conference + err := row.Scan( + &i.ID, + &i.Url, + &i.Title, + &i.Venue, + &i.City, + ) + return i, err +} diff --git a/pkg/database/sqlc/favourites.sql.go b/pkg/database/sqlc/favourites.sql.go index b13261f..d28470d 100644 --- a/pkg/database/sqlc/favourites.sql.go +++ b/pkg/database/sqlc/favourites.sql.go @@ -13,27 +13,34 @@ import ( const createFavourite = `-- name: CreateFavourite :one INSERT INTO favourites ( - user_id, event_guid, event_id + user_id, event_guid, event_id, conference_id ) VALUES ( - $1, $2, $3 + $1, $2, $3, $4 ) -RETURNING id, user_id, event_guid, event_id +RETURNING id, user_id, event_guid, event_id, conference_id ` type CreateFavouriteParams struct { - UserID int32 `json:"user_id"` - EventGuid pgtype.UUID `json:"event_guid"` - EventID pgtype.Int4 `json:"event_id"` + UserID int32 `json:"user_id"` + EventGuid pgtype.UUID `json:"event_guid"` + EventID pgtype.Int4 `json:"event_id"` + ConferenceID int32 `json:"conference_id"` } func (q *Queries) CreateFavourite(ctx context.Context, arg CreateFavouriteParams) (Favourite, error) { - row := q.db.QueryRow(ctx, createFavourite, arg.UserID, arg.EventGuid, arg.EventID) + row := q.db.QueryRow(ctx, createFavourite, + arg.UserID, + arg.EventGuid, + arg.EventID, + arg.ConferenceID, + ) var i Favourite err := row.Scan( &i.ID, &i.UserID, &i.EventGuid, &i.EventID, + &i.ConferenceID, ) return i, err } @@ -50,17 +57,23 @@ func (q *Queries) DeleteFavourite(ctx context.Context, id int32) error { const deleteFavouriteByEventDetails = `-- name: DeleteFavouriteByEventDetails :execrows DELETE FROM favourites -WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3 +WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3 AND conference_id = $4 ` type DeleteFavouriteByEventDetailsParams struct { - EventGuid pgtype.UUID `json:"event_guid"` - EventID pgtype.Int4 `json:"event_id"` - UserID int32 `json:"user_id"` + EventGuid pgtype.UUID `json:"event_guid"` + EventID pgtype.Int4 `json:"event_id"` + UserID int32 `json:"user_id"` + ConferenceID int32 `json:"conference_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) + result, err := q.db.Exec(ctx, deleteFavouriteByEventDetails, + arg.EventGuid, + arg.EventID, + arg.UserID, + arg.ConferenceID, + ) if err != nil { return 0, err } @@ -68,7 +81,7 @@ func (q *Queries) DeleteFavouriteByEventDetails(ctx context.Context, arg DeleteF } const getFavouritesForUser = `-- name: GetFavouritesForUser :many -SELECT id, user_id, event_guid, event_id FROM favourites +SELECT id, user_id, event_guid, event_id, conference_id FROM favourites WHERE user_id = $1 ` @@ -86,6 +99,43 @@ func (q *Queries) GetFavouritesForUser(ctx context.Context, userID int32) ([]Fav &i.UserID, &i.EventGuid, &i.EventID, + &i.ConferenceID, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getFavouritesForUserConference = `-- name: GetFavouritesForUserConference :many +SELECT id, user_id, event_guid, event_id, conference_id FROM favourites +WHERE user_id = $1 AND conference_id = $2 +` + +type GetFavouritesForUserConferenceParams struct { + UserID int32 `json:"user_id"` + ConferenceID int32 `json:"conference_id"` +} + +func (q *Queries) GetFavouritesForUserConference(ctx context.Context, arg GetFavouritesForUserConferenceParams) ([]Favourite, error) { + rows, err := q.db.Query(ctx, getFavouritesForUserConference, arg.UserID, arg.ConferenceID) + 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, + &i.ConferenceID, ); err != nil { return nil, err } diff --git a/pkg/database/sqlc/models.go b/pkg/database/sqlc/models.go index 57fd082..b7dfc19 100644 --- a/pkg/database/sqlc/models.go +++ b/pkg/database/sqlc/models.go @@ -15,15 +15,25 @@ type Calendar struct { Key string `json:"key"` } +type Conference struct { + ID int32 `json:"id"` + Url string `json:"url"` + Title pgtype.Text `json:"title"` + Venue pgtype.Text `json:"venue"` + City pgtype.Text `json:"city"` +} + type Favourite struct { - ID int32 `json:"id"` - UserID int32 `json:"user_id"` - EventGuid pgtype.UUID `json:"event_guid"` - EventID pgtype.Int4 `json:"event_id"` + ID int32 `json:"id"` + UserID int32 `json:"user_id"` + EventGuid pgtype.UUID `json:"event_guid"` + EventID pgtype.Int4 `json:"event_id"` + ConferenceID int32 `json:"conference_id"` } type User struct { ID int32 `json:"id"` Username string `json:"username"` Password pgtype.Text `json:"password"` + Admin bool `json:"admin"` } diff --git a/pkg/database/sqlc/users.sql.go b/pkg/database/sqlc/users.sql.go index cf0aeb9..45ae019 100644 --- a/pkg/database/sqlc/users.sql.go +++ b/pkg/database/sqlc/users.sql.go @@ -17,7 +17,7 @@ INSERT INTO users ( ) VALUES ( $1, $2 ) -RETURNING id, username, password +RETURNING id, username, password, admin ` type CreateUserParams struct { @@ -28,7 +28,12 @@ type CreateUserParams struct { 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) + err := row.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.Admin, + ) return i, err } @@ -43,31 +48,41 @@ func (q *Queries) DeleteUser(ctx context.Context, id int32) error { } const getUserByID = `-- name: GetUserByID :one -SELECT id, username, password FROM users +SELECT id, username, password, admin 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) + err := row.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.Admin, + ) return i, err } const getUserByName = `-- name: GetUserByName :one -SELECT id, username, password FROM users +SELECT id, username, password, admin 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) + err := row.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.Admin, + ) return i, err } const listUsers = `-- name: ListUsers :many -SELECT id, username, password FROM users +SELECT id, username, password, admin FROM users ORDER BY username ` @@ -80,7 +95,12 @@ func (q *Queries) ListUsers(ctx context.Context) ([]User, error) { var items []User for rows.Next() { var i User - if err := rows.Scan(&i.ID, &i.Username, &i.Password); err != nil { + if err := rows.Scan( + &i.ID, + &i.Username, + &i.Password, + &i.Admin, + ); err != nil { return nil, err } items = append(items, i) diff --git a/pkg/favourites/service.go b/pkg/favourites/service.go index cba249e..f73f400 100644 --- a/pkg/favourites/service.go +++ b/pkg/favourites/service.go @@ -12,9 +12,10 @@ import ( ) 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 + GetAllFavouritesForUser(id int32) (*[]sqlc.Favourite, error) + GetFavouritesForUserConference(id int32, conference int32) (*[]sqlc.Favourite, error) + CreateFavouriteForUser(id int32, eventGUID pgtype.UUID, eventID *int32, conferenceID int32) (*sqlc.Favourite, error) + DeleteFavouriteForUserByEventDetails(id int32, eventGUID pgtype.UUID, eventID *int32, conferenceID int32) error } var ( @@ -32,21 +33,22 @@ func NewService(pool *pgxpool.Pool) Service { } } -func (s *service) CreateFavouriteForUser(id int32, eventGuid pgtype.UUID, eventId *int32) (*sqlc.Favourite, error) { +func (s *service) CreateFavouriteForUser(userID int32, eventGUID pgtype.UUID, eventID *int32, conferenceID int32) (*sqlc.Favourite, error) { queries := sqlc.New(s.pool) - var pgEventId pgtype.Int4 - if eventId != nil { - pgEventId = pgtype.Int4{ - Int32: *eventId, + 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, + UserID: userID, + EventGuid: eventGUID, + EventID: pgEventID, + ConferenceID: conferenceID, }) if err != nil { return nil, fmt.Errorf("could not create favourite: %w", err) @@ -55,10 +57,10 @@ func (s *service) CreateFavouriteForUser(id int32, eventGuid pgtype.UUID, eventI return &favourite, nil } -func (s *service) GetFavouritesForUser(id int32) (*[]sqlc.Favourite, error) { +func (s *service) GetAllFavouritesForUser(userID int32) (*[]sqlc.Favourite, error) { queries := sqlc.New(s.pool) - favourites, err := queries.GetFavouritesForUser(context.Background(), id) + favourites, err := queries.GetFavouritesForUser(context.Background(), userID) if err != nil { if errors.Is(err, pgx.ErrNoRows) { empty := make([]sqlc.Favourite, 0) @@ -70,20 +72,39 @@ func (s *service) GetFavouritesForUser(id int32) (*[]sqlc.Favourite, error) { return &favourites, nil } -func (s *service) DeleteFavouriteForUserByEventDetails(id int32, eventGuid pgtype.UUID, eventId *int32) error { +func (s *service) GetFavouritesForUserConference(userID int32, conferenceID int32) (*[]sqlc.Favourite, error) { queries := sqlc.New(s.pool) - var pgEventId pgtype.Int4 - if eventId != nil { - pgEventId = pgtype.Int4{ - Int32: *eventId, + favourites, err := queries.GetFavouritesForUserConference(context.Background(), sqlc.GetFavouritesForUserConferenceParams{ + UserID: userID, + ConferenceID: conferenceID, + }) + 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, conferenceID 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, + EventGuid: eventGUID, + EventID: pgEventID, + UserID: id, + ConferenceID: conferenceID, }) if err != nil { return fmt.Errorf("could not delete favourite: %w", err) diff --git a/pkg/ical/service.go b/pkg/ical/service.go index d93c846..bc82248 100644 --- a/pkg/ical/service.go +++ b/pkg/ical/service.go @@ -6,9 +6,9 @@ import ( "strings" "time" + "github.com/LMBishop/confplanner/pkg/conference" "github.com/LMBishop/confplanner/pkg/database/sqlc" "github.com/LMBishop/confplanner/pkg/favourites" - "github.com/LMBishop/confplanner/pkg/schedule" "github.com/microcosm-cc/bluemonday" ) @@ -23,28 +23,31 @@ var ( type service struct { favouritesService favourites.Service - scheduleService schedule.Service + conferenceService conference.Service } func NewService( favouritesService favourites.Service, - scheduleService schedule.Service, + conferenceService conference.Service, ) Service { return &service{ favouritesService: favouritesService, - scheduleService: scheduleService, + conferenceService: conferenceService, } } func (s *service) GenerateIcalForCalendar(calendar sqlc.Calendar) (string, error) { - favourites, err := s.favouritesService.GetFavouritesForUser(calendar.UserID) + favourites, err := s.favouritesService.GetAllFavouritesForUser(calendar.UserID) if err != nil { return "", err } - events := make([]schedule.Event, 0) + events := make([]conference.Event, 0) for _, favourite := range *favourites { - event := s.scheduleService.GetEventByID(favourite.EventID.Int32) + event, err := s.conferenceService.GetEventByID(favourite.ConferenceID, favourite.EventID.Int32) + if err != nil { + continue + } events = append(events, *event) } diff --git a/pkg/schedule/model.go b/pkg/schedule/model.go deleted file mode 100644 index fcf39a5..0000000 --- a/pkg/schedule/model.go +++ /dev/null @@ -1,72 +0,0 @@ -package schedule - -import "time" - -type Schedule struct { - Conference Conference `json:"conference"` - Tracks []Track `json:"tracks"` - Days []Day `json:"days"` -} - -type Conference struct { - Title string `json:"title"` - Venue string `json:"venue"` - City string `json:"city"` - Start string `json:"start"` - End string `json:"end"` - Days int `json:"days"` - DayChange string `json:"dayChange"` - TimeslotDuration string `json:"timeslotDuration"` - BaseURL string `json:"baseUrl"` - TimeZoneName string `json:"timeZoneName"` -} - -type Track struct { - Name string `json:"name"` -} - -type Day struct { - Date string `json:"date"` - Start time.Time `json:"start"` - End time.Time `json:"end"` - Rooms []Room `json:"rooms"` -} - -type Room struct { - Name string `json:"name"` - Events []Event `json:"events"` -} - -type Event struct { - ID int32 `json:"id"` - GUID string `json:"guid"` - Date string `json:"date"` - Start time.Time `json:"start"` - End time.Time `json:"end"` - Duration int32 `json:"duration"` - Room string `json:"room"` - URL string `json:"url"` - Track string `json:"track"` - Type string `json:"type"` - Title string `json:"title"` - Abstract string `json:"abstract"` - Persons []Person `json:"persons"` - Attachments []Attachment `json:"attachments"` - Links []Link `json:"links"` -} - -type Person struct { - ID int `json:"id"` - Name string `json:"name"` -} - -type Attachment struct { - Type string `json:"string"` - Href string `json:"href"` - Name string `json:"name"` -} - -type Link struct { - Href string `json:"href"` - Name string `json:"name"` -} diff --git a/pkg/schedule/parse.go b/pkg/schedule/parse.go deleted file mode 100644 index b0ed8df..0000000 --- a/pkg/schedule/parse.go +++ /dev/null @@ -1,205 +0,0 @@ -package schedule - -import ( - "encoding/xml" - "fmt" - "time" -) - -type schedule struct { - XMLName xml.Name `xml:"schedule"` - Conference conference `xml:"conference"` - Tracks []track `xml:"tracks>track"` - Days []day `xml:"day"` -} - -type conference struct { - Title string `xml:"title"` - Venue string `xml:"venue"` - City string `xml:"city"` - Start string `xml:"start"` - End string `xml:"end"` - Days int `xml:"days"` - DayChange string `xml:"day_change"` - TimeslotDuration string `xml:"timeslot_duration"` - BaseURL string `xml:"base_url"` - TimeZoneName string `xml:"time_zone_name"` -} - -type track struct { - Name string `xml:",chardata"` -} - -type day struct { - Date string `xml:"date,attr"` - Start string `xml:"start,attr"` - End string `xml:"end,attr"` - Rooms []room `xml:"room"` -} - -type room struct { - Name string `xml:"name,attr"` - Events []event `xml:"event"` -} - -type event struct { - ID int32 `xml:"id,attr"` - GUID string `xml:"guid,attr"` - Date string `xml:"date"` - Start string `xml:"start"` - Duration string `xml:"duration"` - Room string `xml:"room"` - URL string `xml:"url"` - Track string `xml:"track"` - Type string `xml:"type"` - Title string `xml:"title"` - Abstract string `xml:"abstract"` - Persons []person `xml:"persons>person"` - Attachments []attachment `xml:"attachments>attachment"` - Links []link `xml:"links>link"` -} - -type person struct { - ID int `xml:"id,attr"` - Name string `xml:",chardata"` -} - -type attachment struct { - Type string `xml:"id,attr"` - Href string `xml:"href,attr"` - Name string `xml:",chardata"` -} - -type link struct { - Href string `xml:"href,attr"` - Name string `xml:",chardata"` -} - -func (dst *Schedule) Scan(src schedule) error { - dst.Conference.Scan(src.Conference) - - dst.Tracks = make([]Track, len(src.Tracks)) - for i := range src.Tracks { - dst.Tracks[i].Scan(src.Tracks[i]) - } - dst.Days = make([]Day, len(src.Days)) - for i := range src.Days { - if err := dst.Days[i].Scan(src.Days[i]); err != nil { - return fmt.Errorf("failed to scan day: %w", err) - } - } - return nil -} - -func (dst *Conference) Scan(src conference) { - dst.Title = src.Title - dst.Venue = src.Venue - dst.City = src.City - dst.Start = src.Start - dst.End = src.End - dst.Days = src.Days - dst.DayChange = src.DayChange - dst.TimeslotDuration = src.TimeslotDuration - dst.BaseURL = src.BaseURL - dst.TimeZoneName = src.TimeZoneName -} - -func (dst *Track) Scan(src track) { - dst.Name = src.Name -} - -func (dst *Day) Scan(src day) error { - dst.Date = src.Date - - start, err := time.Parse(time.RFC3339, src.Start) - if err != nil { - return fmt.Errorf("failed to parse start time: %w", err) - } - end, err := time.Parse(time.RFC3339, src.End) - if err != nil { - return fmt.Errorf("failed to parse end time: %w", err) - } - - dst.Start = start - dst.End = end - - dst.Rooms = make([]Room, len(src.Rooms)) - for i := range src.Rooms { - dst.Rooms[i].Scan(src.Rooms[i]) - } - return nil -} - -func (dst *Room) Scan(src room) { - dst.Name = src.Name - - dst.Events = make([]Event, len(src.Events)) - for i := range src.Events { - dst.Events[i].Scan(src.Events[i]) - } -} - -func (dst *Event) Scan(src event) error { - dst.ID = src.ID - dst.GUID = src.GUID - dst.Date = src.Date - - duration, err := parseDuration(src.Duration) - if err != nil { - return err - } - start, err := time.Parse(time.RFC3339, src.Date) - if err != nil { - start = time.Unix(0, 0) - } - dst.Start = start - dst.End = start.Add(time.Minute * time.Duration(duration)) - - dst.Room = src.Room - dst.URL = src.URL - dst.Track = src.Track - dst.Type = src.Type - dst.Title = src.Title - dst.Abstract = src.Abstract - - dst.Persons = make([]Person, len(src.Persons)) - for i := range src.Persons { - dst.Persons[i].Scan(src.Persons[i]) - } - - dst.Attachments = make([]Attachment, len(src.Attachments)) - for i := range src.Attachments { - dst.Attachments[i].Scan(src.Attachments[i]) - } - - dst.Links = make([]Link, len(src.Links)) - for i := range src.Links { - dst.Links[i].Scan(src.Links[i]) - } - - return nil -} - -func (dst *Person) Scan(src person) { - dst.ID = src.ID - dst.Name = src.Name -} - -func (dst *Attachment) Scan(src attachment) { - dst.Type = src.Type - dst.Href = src.Href - dst.Name = src.Name -} - -func (dst *Link) Scan(src link) { - dst.Href = src.Href - dst.Name = src.Name -} - -func parseDuration(duration string) (int32, error) { - d, err := time.Parse("15:04", duration) - if err != nil { - return 0, err - } - return int32(d.Minute() + d.Hour()*60), nil -} diff --git a/pkg/schedule/service.go b/pkg/schedule/service.go deleted file mode 100644 index a73a469..0000000 --- a/pkg/schedule/service.go +++ /dev/null @@ -1,116 +0,0 @@ -package schedule - -import ( - "bufio" - "encoding/xml" - "fmt" - "net/http" - "sync" - "time" -) - -type Service interface { - GetSchedule() (*Schedule, time.Time, error) - GetEventByID(id int32) *Event -} - -type service struct { - pentabarfUrl string - - schedule *Schedule - eventsById map[int32]Event - lastUpdated time.Time - accessLock sync.RWMutex - updateLock sync.Mutex -} - -// TODO: Create a service implementation that persists to DB -// and isn't in memory -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) { - err := s.updateSchedule() - if err != nil { - return nil, time.Time{}, err - } - - s.accessLock.RLock() - defer s.accessLock.RUnlock() - - return s.schedule, s.lastUpdated, nil -} - -func (s *service) GetEventByID(id int32) *Event { - s.accessLock.RLock() - defer s.accessLock.RUnlock() - - event := s.eventsById[id] - - return &event -} - -func (s *service) hasScheduleExpired() bool { - expire := s.lastUpdated.Add(15 * time.Minute) - return time.Now().After(expire) -} - -func (s *service) updateSchedule() error { - if !s.hasScheduleExpired() { - return nil - } - - if !s.updateLock.TryLock() { - // don't block if another goroutine is already fetching - return nil - } - defer s.updateLock.Unlock() - - 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) - } - - var newSchedule Schedule - err = newSchedule.Scan(schedule) - if err != nil { - return fmt.Errorf("failed to scan schedule: %w", err) - } - - s.accessLock.Lock() - defer s.accessLock.Unlock() - - s.schedule = &newSchedule - s.lastUpdated = time.Now() - - s.eventsById = make(map[int32]Event) - - for _, day := range newSchedule.Days { - for _, room := range day.Rooms { - for _, event := range room.Events { - s.eventsById[event.ID] = event - } - } - } - - return nil -} diff --git a/pkg/session/memory.go b/pkg/session/memory.go index f02b792..8fa6076 100644 --- a/pkg/session/memory.go +++ b/pkg/session/memory.go @@ -40,7 +40,7 @@ func (s *memoryStore) GetBySID(sid uint) *UserSession { return s.sessionsBySID[sid] } -func (s *memoryStore) Create(uid int32, username string, ip string, ua string) (*UserSession, error) { +func (s *memoryStore) Create(uid int32, username string, ip string, ua string, admin bool) (*UserSession, error) { token := generateSessionToken() s.lock.Lock() @@ -65,6 +65,7 @@ func (s *memoryStore) Create(uid int32, username string, ip string, ua string) ( IP: ip, UserAgent: ua, LoginTime: time.Now(), + Admin: admin, } s.sessionsByToken[token] = session s.sessionsBySID[sessionId] = session diff --git a/pkg/session/service.go b/pkg/session/service.go index 83009bc..a693fe8 100644 --- a/pkg/session/service.go +++ b/pkg/session/service.go @@ -5,7 +5,7 @@ import "time" type Service interface { GetByToken(token string) *UserSession GetBySID(sid uint) *UserSession - Create(uid int32, username string, ip string, ua string) (*UserSession, error) + Create(uid int32, username string, ip string, ua string, admin bool) (*UserSession, error) Destroy(sid uint) error } @@ -17,4 +17,5 @@ type UserSession struct { IP string LoginTime time.Time UserAgent string + Admin bool } -- cgit v1.2.3-70-g09d2