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 --- Makefile | 2 +- api/dto/auth.go | 4 +- api/dto/conference.go | 36 ++++ api/dto/favourites.go | 10 +- api/dto/schedule.go | 10 - api/dto/util.go | 6 +- api/handlers/auth.go | 11 +- api/handlers/conference.go | 103 ++++++++++ api/handlers/favourites.go | 19 +- api/handlers/schedule.go | 26 --- api/middleware/admin.go | 27 +++ api/middleware/auth.go | 38 +++- api/router.go | 14 +- main.go | 8 +- 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 +- web/assets/css/main.css | 44 +++++ web/components/AddConference.vue | 54 ++++++ web/components/Button.vue | 2 +- web/components/Dialog.vue | 2 +- web/components/EventDetail.vue | 2 +- web/components/EventListing.vue | 10 +- web/components/Input.vue | 2 +- web/components/Panel.vue | 15 +- web/components/Sidebar.vue | 47 ++--- web/composables/api.ts | 14 ++ web/composables/expire-auth.ts | 8 + web/composables/fetch-favourites.ts | 36 ++-- web/composables/fetch-login.ts | 6 +- web/composables/fetch-schedule.ts | 57 +++--- web/composables/logout.ts | 15 ++ web/layouts/default.vue | 49 ++--- web/middleware/conference-selected.ts | 15 ++ web/middleware/logged-in.ts | 19 +- web/nuxt.config.ts | 2 +- web/package-lock.json | 41 ++++ web/package.json | 1 + web/pages/agenda.vue | 52 +++--- web/pages/conferences.vue | 124 ++++++++++++ web/pages/events.vue | 11 +- web/pages/index.vue | 9 + web/pages/live.vue | 9 + web/pages/login/[[provider]].vue | 98 +++++----- web/pages/tracks/[slug].vue | 9 + web/pages/tracks/index.vue | 12 +- web/stores/auth.ts | 12 ++ web/stores/conference.ts | 18 ++ web/stores/favourites.ts | 6 +- web/stores/login-options.ts | 10 +- web/stores/schedule.ts | 8 +- 66 files changed, 1661 insertions(+), 741 deletions(-) create mode 100644 api/dto/conference.go delete mode 100644 api/dto/schedule.go create mode 100644 api/handlers/conference.go delete mode 100644 api/handlers/schedule.go create mode 100644 api/middleware/admin.go 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 create mode 100644 web/components/AddConference.vue create mode 100644 web/composables/api.ts create mode 100644 web/composables/expire-auth.ts create mode 100644 web/composables/logout.ts create mode 100644 web/middleware/conference-selected.ts create mode 100644 web/pages/conferences.vue create mode 100644 web/stores/auth.ts create mode 100644 web/stores/conference.ts diff --git a/Makefile b/Makefile index 32f4e10..a986975 100644 --- a/Makefile +++ b/Makefile @@ -12,4 +12,4 @@ web: .PHONY: sqlc sqlc: - sqlc compile + sqlc generate diff --git a/api/dto/auth.go b/api/dto/auth.go index 0379f21..734bb31 100644 --- a/api/dto/auth.go +++ b/api/dto/auth.go @@ -11,12 +11,14 @@ type LoginOAuthCallbackRequest struct { } type LoginOAuthOutboundResponse struct { - URL string `json:"url" validate:"required"` + URL string `json:"url"` } type LoginResponse struct { ID int32 `json:"id"` + Token string `json:"token"` Username string `json:"username"` + Admin bool `json:"admin"` } type LoginOptionsResponse struct { diff --git a/api/dto/conference.go b/api/dto/conference.go new file mode 100644 index 0000000..99e6c08 --- /dev/null +++ b/api/dto/conference.go @@ -0,0 +1,36 @@ +package dto + +import ( + "time" + + "github.com/LMBishop/confplanner/pkg/database/sqlc" +) + +type ConferenceResponse struct { + ID int32 `json:"id"` + Title string `json:"title"` + URL string `json:"url"` + Venue string `json:"venue"` + City string `json:"city"` +} + +func (dst *ConferenceResponse) Scan(src sqlc.Conference) { + dst.ID = src.ID + dst.Title = src.Title.String + dst.URL = src.Url + dst.Venue = src.Venue.String + dst.City = src.City.String +} + +type GetScheduleResponse struct { + Schedule interface{} `json:"schedule"` + LastUpdated time.Time `json:"lastUpdated"` +} + +type CreateConferenceRequest struct { + URL string `json:"url" validate:"required"` +} + +type DeleteConferenceRequest struct { + ID int32 `json:"id"` +} diff --git a/api/dto/favourites.go b/api/dto/favourites.go index 0f8021f..44c68a2 100644 --- a/api/dto/favourites.go +++ b/api/dto/favourites.go @@ -3,8 +3,9 @@ package dto import "github.com/LMBishop/confplanner/pkg/database/sqlc" type CreateFavouritesRequest struct { - GUID *string `json:"eventGuid"` - ID *int32 `json:"eventId"` + ConferenceID int32 `json:"conferenceID" validate:"required"` + GUID *string `json:"eventGuid"` + EventID *int32 `json:"eventId"` } type CreateFavouritesResponse struct { @@ -29,6 +30,7 @@ func (dst *GetFavouritesResponse) Scan(src sqlc.Favourite) { } type DeleteFavouritesRequest struct { - GUID *string `json:"eventGuid"` - ID *int32 `json:"eventId"` + ConferenceID int32 `json:"conferenceID" validate:"required"` + GUID *string `json:"eventGuid"` + EventID *int32 `json:"eventId"` } diff --git a/api/dto/schedule.go b/api/dto/schedule.go deleted file mode 100644 index 0d652aa..0000000 --- a/api/dto/schedule.go +++ /dev/null @@ -1,10 +0,0 @@ -package dto - -import ( - "time" -) - -type GetScheduleResponse struct { - Schedule interface{} `json:"schedule"` - LastUpdated time.Time `json:"lastUpdated"` -} diff --git a/api/dto/util.go b/api/dto/util.go index cb3ea52..651f026 100644 --- a/api/dto/util.go +++ b/api/dto/util.go @@ -36,8 +36,8 @@ func WrapResponseFunc(dtoFunc func(http.ResponseWriter, *http.Request) error) ht } } -func WriteDto(w http.ResponseWriter, r *http.Request, o error) { - if o, ok := o.(Response); ok { +func WriteDto(w http.ResponseWriter, r *http.Request, err error) { + if o, ok := err.(Response); ok { data, err := json.Marshal(o) if err != nil { w.WriteHeader(500) @@ -49,6 +49,6 @@ func WriteDto(w http.ResponseWriter, r *http.Request, o error) { w.Write(data) } else { w.WriteHeader(500) - slog.Error("internal server error handling request", "error", o) + slog.Error("internal server error handling request", "error", err) } } diff --git a/api/handlers/auth.go b/api/handlers/auth.go index c19fc3a..7b0a4c8 100644 --- a/api/handlers/auth.go +++ b/api/handlers/auth.go @@ -34,23 +34,18 @@ func Login(authService auth.Service, store session.Service) http.HandlerFunc { } // TODO X-Forwarded-For - session, err := store.Create(user.ID, user.Username, r.RemoteAddr, r.UserAgent()) + session, err := store.Create(user.ID, user.Username, r.RemoteAddr, r.UserAgent(), user.Admin) if err != nil { return err } - cookie := &http.Cookie{ - Name: "confplanner_session", - Value: session.Token, - Path: "/api", - } - http.SetCookie(w, cookie) - return &dto.OkResponse{ Code: http.StatusOK, Data: &dto.LoginResponse{ ID: user.ID, + Token: session.Token, Username: user.Username, + Admin: session.Admin, }, } }) diff --git a/api/handlers/conference.go b/api/handlers/conference.go new file mode 100644 index 0000000..42cbfc1 --- /dev/null +++ b/api/handlers/conference.go @@ -0,0 +1,103 @@ +package handlers + +import ( + "errors" + "net/http" + "strconv" + + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/conference" + "github.com/golang-cz/nilslice" +) + +func GetSchedule(service conference.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + conferenceID, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + return &dto.ErrorResponse{ + Code: http.StatusBadRequest, + Message: "Bad conference ID", + } + } + + schedule, lastUpdated, err := service.GetSchedule(int32(conferenceID)) + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: http.StatusOK, + Data: &dto.GetScheduleResponse{ + Schedule: nilslice.Initialize(*schedule), + LastUpdated: lastUpdated, + }, + } + }) +} + +func GetConferences(service conference.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + conferences, err := service.GetConferences() + if err != nil { + return err + } + + var conferencesResponse []*dto.ConferenceResponse + for _, c := range conferences { + conference := &dto.ConferenceResponse{} + conference.Scan(c) + conferencesResponse = append(conferencesResponse, conference) + } + + return &dto.OkResponse{ + Code: http.StatusOK, + Data: conferencesResponse, + } + }) +} + +func CreateConference(service conference.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + var request dto.CreateConferenceRequest + if err := dto.ReadDto(r, &request); err != nil { + return err + } + + createdConference, err := service.CreateConference(request.URL) + if err != nil { + if errors.Is(err, conference.ErrScheduleFetch) { + return &dto.ErrorResponse{ + Code: http.StatusBadRequest, + Message: "Could not fetch schedule from URL (is it a valid pentabarf XML file?)", + } + } + return err + } + + var response dto.ConferenceResponse + response.Scan(*createdConference) + return &dto.OkResponse{ + Code: http.StatusCreated, + Data: response, + } + }) +} + +func DeleteConference(service conference.Service) http.HandlerFunc { + return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + var request dto.DeleteConferenceRequest + if err := dto.ReadDto(r, &request); err != nil { + return err + } + + err := service.DeleteConference(request.ID) + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: http.StatusOK, + Data: nil, + } + }) +} diff --git a/api/handlers/favourites.go b/api/handlers/favourites.go index 502cb43..d5fe8c3 100644 --- a/api/handlers/favourites.go +++ b/api/handlers/favourites.go @@ -2,6 +2,7 @@ package handlers import ( "net/http" + "strconv" "github.com/LMBishop/confplanner/api/dto" "github.com/LMBishop/confplanner/pkg/favourites" @@ -16,7 +17,7 @@ func CreateFavourite(service favourites.Service) http.HandlerFunc { return err } - if request.GUID == nil && request.ID == nil { + if request.GUID == nil && request.EventID == nil { return &dto.ErrorResponse{ Code: http.StatusBadRequest, Message: "One of event GUID or event ID must be specified", @@ -34,7 +35,7 @@ func CreateFavourite(service favourites.Service) http.HandlerFunc { } } - createdFavourite, err := service.CreateFavouriteForUser(session.UserID, uuid, request.ID) + createdFavourite, err := service.CreateFavouriteForUser(session.UserID, uuid, request.EventID, request.ConferenceID) if err != nil { return err } @@ -50,9 +51,17 @@ func CreateFavourite(service favourites.Service) http.HandlerFunc { func GetFavourites(service favourites.Service) http.HandlerFunc { return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { + conferenceID, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + return &dto.ErrorResponse{ + Code: http.StatusBadRequest, + Message: "Bad conference ID", + } + } + session := r.Context().Value("session").(*session.UserSession) - favourites, err := service.GetFavouritesForUser(session.UserID) + favourites, err := service.GetFavouritesForUserConference(session.UserID, int32(conferenceID)) if err != nil { return err } @@ -79,7 +88,7 @@ func DeleteFavourite(service favourites.Service) http.HandlerFunc { return err } - if request.GUID == nil && request.ID == nil { + if request.GUID == nil && request.EventID == nil { return &dto.ErrorResponse{ Code: http.StatusBadRequest, Message: "One of event GUID or event ID must be specified", @@ -96,7 +105,7 @@ func DeleteFavourite(service favourites.Service) http.HandlerFunc { } } - err = service.DeleteFavouriteForUserByEventDetails(session.UserID, uuid, request.ID) + err = service.DeleteFavouriteForUserByEventDetails(session.UserID, uuid, request.EventID, request.ConferenceID) if err != nil { if err == favourites.ErrNotFound { return &dto.ErrorResponse{ diff --git a/api/handlers/schedule.go b/api/handlers/schedule.go deleted file mode 100644 index 061e6f9..0000000 --- a/api/handlers/schedule.go +++ /dev/null @@ -1,26 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/LMBishop/confplanner/api/dto" - "github.com/LMBishop/confplanner/pkg/schedule" - "github.com/golang-cz/nilslice" -) - -func GetSchedule(service schedule.Service) http.HandlerFunc { - return dto.WrapResponseFunc(func(w http.ResponseWriter, r *http.Request) error { - schedule, lastUpdated, err := service.GetSchedule() - if err != nil { - return err - } - - return &dto.OkResponse{ - Code: http.StatusOK, - Data: &dto.GetScheduleResponse{ - Schedule: nilslice.Initialize(*schedule), - LastUpdated: lastUpdated, - }, - } - }) -} diff --git a/api/middleware/admin.go b/api/middleware/admin.go new file mode 100644 index 0000000..fd43cd6 --- /dev/null +++ b/api/middleware/admin.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "net/http" + + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/session" + "github.com/LMBishop/confplanner/pkg/user" +) + +func MustAuthoriseAdmin(service user.Service, store session.Service) func(http.HandlerFunc) http.HandlerFunc { + return func(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + session := r.Context().Value("session").(*session.UserSession) + + if !session.Admin { + dto.WriteDto(w, r, &dto.ErrorResponse{ + Code: http.StatusForbidden, + Message: "Forbidden", + }) + return + } + + next(w, r) + } + } +} diff --git a/api/middleware/auth.go b/api/middleware/auth.go index eb362b0..438a8a1 100644 --- a/api/middleware/auth.go +++ b/api/middleware/auth.go @@ -3,7 +3,9 @@ package middleware import ( "context" "errors" + "fmt" "net/http" + "strings" "github.com/LMBishop/confplanner/api/dto" "github.com/LMBishop/confplanner/pkg/session" @@ -13,15 +15,17 @@ import ( func MustAuthenticate(service user.Service, store session.Service) func(http.HandlerFunc) http.HandlerFunc { return func(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - var sessionToken string - for _, cookie := range r.Cookies() { - if cookie.Name == "confplanner_session" { - sessionToken = cookie.Value - break - } + authHeader := r.Header.Get("Authorization") + token, err := extractBearerToken(authHeader) + if err != nil { + dto.WriteDto(w, r, &dto.ErrorResponse{ + Code: http.StatusUnauthorized, + Message: "Unauthorized", + }) + return } - s := store.GetByToken(sessionToken) + s := store.GetByToken(token) if s == nil { dto.WriteDto(w, r, &dto.ErrorResponse{ Code: http.StatusUnauthorized, @@ -30,7 +34,7 @@ func MustAuthenticate(service user.Service, store session.Service) func(http.Han return } - _, err := service.GetUserByID(s.UserID) + u, err := service.GetUserByID(s.UserID) if err != nil { if errors.Is(err, user.ErrUserNotFound) { store.Destroy(s.SessionID) @@ -44,9 +48,27 @@ func MustAuthenticate(service user.Service, store session.Service) func(http.Han return } + s.Username = u.Username + s.Admin = u.Admin + ctx := context.WithValue(r.Context(), "session", s) next(w, r.WithContext(ctx)) } } } + +func extractBearerToken(header string) (string, error) { + const prefix = "Bearer " + if header == "" { + return "", fmt.Errorf("authorization header missing") + } + if !strings.HasPrefix(header, prefix) { + return "", fmt.Errorf("invalid authorization scheme") + } + token := strings.TrimSpace(header[len(prefix):]) + if token == "" { + return "", fmt.Errorf("token is empty") + } + return token, nil +} diff --git a/api/router.go b/api/router.go index 38e67e6..af52785 100644 --- a/api/router.go +++ b/api/router.go @@ -7,9 +7,9 @@ import ( "github.com/LMBishop/confplanner/api/middleware" "github.com/LMBishop/confplanner/pkg/auth" "github.com/LMBishop/confplanner/pkg/calendar" + "github.com/LMBishop/confplanner/pkg/conference" "github.com/LMBishop/confplanner/pkg/favourites" "github.com/LMBishop/confplanner/pkg/ical" - "github.com/LMBishop/confplanner/pkg/schedule" "github.com/LMBishop/confplanner/pkg/session" "github.com/LMBishop/confplanner/pkg/user" ) @@ -17,7 +17,7 @@ import ( type ApiServices struct { UserService user.Service FavouritesService favourites.Service - ScheduleService schedule.Service + ConferenceService conference.Service CalendarService calendar.Service IcalService ical.Service SessionService session.Service @@ -26,6 +26,7 @@ type ApiServices struct { func NewServer(apiServices ApiServices, baseURL string) *http.ServeMux { mustAuthenticate := middleware.MustAuthenticate(apiServices.UserService, apiServices.SessionService) + admin := middleware.MustAuthoriseAdmin(apiServices.UserService, apiServices.SessionService) mux := http.NewServeMux() @@ -34,12 +35,15 @@ func NewServer(apiServices ApiServices, baseURL string) *http.ServeMux { mux.HandleFunc("POST /login/{provider}", handlers.Login(apiServices.AuthService, apiServices.SessionService)) mux.HandleFunc("POST /logout", mustAuthenticate(handlers.Logout(apiServices.SessionService))) - mux.HandleFunc("GET /favourites", mustAuthenticate(handlers.GetFavourites(apiServices.FavouritesService))) + mux.HandleFunc("GET /conference", mustAuthenticate(handlers.GetConferences(apiServices.ConferenceService))) + mux.HandleFunc("GET /conference/{id}", mustAuthenticate(handlers.GetSchedule(apiServices.ConferenceService))) + mux.HandleFunc("POST /conference", mustAuthenticate(admin(handlers.CreateConference(apiServices.ConferenceService)))) + mux.HandleFunc("DELETE /conference", mustAuthenticate(admin(handlers.DeleteConference(apiServices.ConferenceService)))) + + mux.HandleFunc("GET /favourites/{id}", mustAuthenticate(handlers.GetFavourites(apiServices.FavouritesService))) mux.HandleFunc("POST /favourites", mustAuthenticate(handlers.CreateFavourite(apiServices.FavouritesService))) mux.HandleFunc("DELETE /favourites", mustAuthenticate(handlers.DeleteFavourite(apiServices.FavouritesService))) - mux.HandleFunc("GET /schedule", mustAuthenticate(handlers.GetSchedule(apiServices.ScheduleService))) - mux.HandleFunc("GET /calendar", mustAuthenticate(handlers.GetCalendar(apiServices.CalendarService, baseURL))) mux.HandleFunc("POST /calendar", mustAuthenticate(handlers.CreateCalendar(apiServices.CalendarService, baseURL))) mux.HandleFunc("DELETE /calendar", mustAuthenticate(handlers.DeleteCalendar(apiServices.CalendarService))) diff --git a/main.go b/main.go index 545ac82..48f2e74 100644 --- a/main.go +++ b/main.go @@ -10,10 +10,10 @@ import ( "github.com/LMBishop/confplanner/internal/config" "github.com/LMBishop/confplanner/pkg/auth" "github.com/LMBishop/confplanner/pkg/calendar" + "github.com/LMBishop/confplanner/pkg/conference" "github.com/LMBishop/confplanner/pkg/database" "github.com/LMBishop/confplanner/pkg/favourites" "github.com/LMBishop/confplanner/pkg/ical" - "github.com/LMBishop/confplanner/pkg/schedule" "github.com/LMBishop/confplanner/pkg/session" "github.com/LMBishop/confplanner/pkg/user" "github.com/LMBishop/confplanner/web" @@ -44,12 +44,12 @@ func run() error { userService := user.NewService(pool, c.AcceptRegistrations) favouritesService := favourites.NewService(pool) - scheduleService, err := schedule.NewService(c.Conference.ScheduleURL) + conferenceService, err := conference.NewService(pool) if err != nil { return fmt.Errorf("failed to create schedule service: %w", err) } calendarService := calendar.NewService(pool) - icalService := ical.NewService(favouritesService, scheduleService) + icalService := ical.NewService(favouritesService, conferenceService) sessionService := session.NewMemoryStore() authService := auth.NewService() @@ -82,7 +82,7 @@ func run() error { api := api.NewServer(api.ApiServices{ UserService: userService, FavouritesService: favouritesService, - ScheduleService: scheduleService, + ConferenceService: conferenceService, CalendarService: calendarService, IcalService: icalService, SessionService: sessionService, 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 } diff --git a/web/assets/css/main.css b/web/assets/css/main.css index 320bedb..5b88e8c 100644 --- a/web/assets/css/main.css +++ b/web/assets/css/main.css @@ -99,4 +99,48 @@ span.text-icon { display: flex; align-items: center; gap: 0.4em; +} + +form { + display: grid; + gap: 1.5rem; +} + +div.form-group { + display: flex; + flex-direction: column; +} + +label.form-label { + display: block; + font-size: 0.875rem; + font-weight: 500; + color: #374151; +} + +div.form-input-container { + margin-top: 0.25rem; +} + +div.form-submit { + display: flex; + justify-content: flex-end; +} + +.loading-text { + display: flex; + justify-content: center; + align-items: center; + gap: 0.5rem; + font-size: var(--text-normal); + color: var(--color-text-muted); +} + +table { + width: 100%; +} + +th { + font-weight: bold; + text-align: left; } \ No newline at end of file diff --git a/web/components/AddConference.vue b/web/components/AddConference.vue new file mode 100644 index 0000000..95154d4 --- /dev/null +++ b/web/components/AddConference.vue @@ -0,0 +1,54 @@ + + + + + \ No newline at end of file diff --git a/web/components/Button.vue b/web/components/Button.vue index ce9eefc..27cfa4e 100644 --- a/web/components/Button.vue +++ b/web/components/Button.vue @@ -48,7 +48,7 @@ button { gap: 0.4rem; padding: 0.5rem 1rem; border: 1px solid transparent; - border-radius: 0.375rem; + border-radius: 2px; font-size: var(--text-small); font-family: var(--font-family); font-weight: 500; diff --git a/web/components/Dialog.vue b/web/components/Dialog.vue index 7772f23..04e1461 100644 --- a/web/components/Dialog.vue +++ b/web/components/Dialog.vue @@ -72,7 +72,7 @@ const onDialogClick = (e: MouseEvent) => { \ No newline at end of file diff --git a/web/pages/events.vue b/web/pages/events.vue index 093e959..d369bdc 100644 --- a/web/pages/events.vue +++ b/web/pages/events.vue @@ -2,12 +2,21 @@ import { Calendar, SquareGanttChart } from 'lucide-vue-next'; import { useScheduleStore } from '~/stores/schedule'; +definePageMeta({ + middleware: ['logged-in', 'conference-selected'] +}) + const scheduleStore = useScheduleStore();