diff options
| author | Leonardo Bishop <me@leonardobishop.com> | 2025-08-23 22:29:28 +0100 |
|---|---|---|
| committer | Leonardo Bishop <me@leonardobishop.com> | 2025-08-23 22:29:28 +0100 |
| commit | ecc6a55aba7bb35fc778e7a53848396b88214151 (patch) | |
| tree | 1b37a2dc5f4594155114da1ae0c4529d20a4c548 | |
| parent | 8f7dec8ba6b2f9bde01afd0a110596ebbd43e0ed (diff) | |
Add multiple conferences feature
64 files changed, 1384 insertions, 464 deletions
@@ -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))) @@ -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/schedule/model.go b/pkg/conference/model.go index fcf39a5..343271f 100644 --- a/pkg/schedule/model.go +++ b/pkg/conference/model.go @@ -1,4 +1,4 @@ -package schedule +package conference import "time" diff --git a/pkg/schedule/parse.go b/pkg/conference/parse.go index b0ed8df..b7f9dba 100644 --- a/pkg/schedule/parse.go +++ b/pkg/conference/parse.go @@ -1,4 +1,4 @@ -package schedule +package conference import ( "encoding/xml" 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/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 @@ +<script setup lang="ts"> +import { format } from 'date-fns'; +import { type Event as ScheduledEvent } from '~/stores/schedule'; + +const errorStore = useErrorStore(); +const config = useRuntimeConfig(); +const loading = ref(false) + +const emit = defineEmits(['update']); + +const addConference = async (e: Event) => { + const target = e.target as HTMLFormElement; + const formData = new FormData(target); + loading.value = true + + $api(config.public.baseURL + '/conference', { + method: 'POST', + body: JSON.stringify(Object.fromEntries(formData)), + onResponse: ({ response }) => { + loading.value = false + if (!response.ok) { + errorStore.setError(response._data?.message || 'An unknown error occurred'); + return + } + emit('update') + }, + }); +} + +</script> + +<template> + <div> + <form @submit.prevent="(e) => addConference(e)"> + <div class="form-group"> + <label for="url" class="form-label"> + Schedule data URL + </label> + <div class="form-input-container"> + <Input id="url" name="url" required /> + </div> + </div> + + <div class="form-submit"> + <Button type="submit" :loading="loading"> + Add + </Button> + </div> + </form> + </div> +</template> + +<style scoped> +</style>
\ 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) => { <style scoped> dialog { outline: none; - border-radius: 0.5rem; + border-radius: 2px; padding: 1rem; width: 1000px; margin: 0; diff --git a/web/components/EventDetail.vue b/web/components/EventDetail.vue index b4f7bd9..8fd5b03 100644 --- a/web/components/EventDetail.vue +++ b/web/components/EventDetail.vue @@ -43,7 +43,7 @@ const getHostname = (url: string) => new URL(url).hostname; </div> <div> - <span class="event-track"><NuxtLink :to="'/tracks/' + event.track.slug">{{ event.track.name }}</NuxtLink> •</span> <a v-if="event.url" class="event-url" :href="event.url" target="_blank">view on {{ getHostname(event.url)}}</a> + <span v-if="event.track" class="event-track"><NuxtLink :to="'/tracks/' + event.track.slug">{{ event.track.name }}</NuxtLink> •</span> <a v-if="event.url" class="event-url" :href="event.url" target="_blank">view on {{ getHostname(event.url)}}</a> </div> </div> </template> diff --git a/web/components/EventListing.vue b/web/components/EventListing.vue index 5c04189..0cc546c 100644 --- a/web/components/EventListing.vue +++ b/web/components/EventListing.vue @@ -10,6 +10,7 @@ const { event, showRelativeTime } = defineProps<{ }>(); const selectedEventStore = useSelectedEventStore(); +const conferenceStore = useConferenceStore(); const favouritesStore = useFavouritesStore(); const errorStore = useErrorStore(); const config = useRuntimeConfig(); @@ -43,9 +44,10 @@ const addFavourite = async () => { addingToFavourite.value = true; try { - const res = await $fetch(config.public.baseURL + '/favourites', { + const res = await $api(config.public.baseURL + '/favourites', { method: 'POST', body: JSON.stringify({ + conferenceId: conferenceStore.id, eventGuid: event.guid, eventId: event.id, }), @@ -70,7 +72,7 @@ const removeFavourite = async () => { addingToFavourite.value = true; try { - await $fetch(config.public.baseURL + '/favourites', { + await $api(config.public.baseURL + '/favourites', { method: 'DELETE', body: JSON.stringify({ eventGuid: event.guid, @@ -102,9 +104,9 @@ const removeFavourite = async () => { </span> <span class="event-title">{{ event.title }}</span> <span class="event-speaker">{{ event.persons.map(p => p.name).join(", ") }}</span> - <span class="event-track">{{ event.track.name }}</span> + <span class="event-track">{{ event.track?.name }}</span> </div> - <template v-if="!addingToFavourite" class="event-button"> + <template v-if="!addingToFavourite && favouritesStore.status !== 'pending'" class="event-button"> <StarIcon v-if="favouritesStore.isFavourite(event)" color="var(--color-favourite)" fill="var(--color-favourite)" class="event-button" @click="removeFavourite" /> <StarIcon v-else color="var(--color-text-muted)" class="event-button" @click="addFavourite" /> </template> diff --git a/web/components/Input.vue b/web/components/Input.vue index b541566..aebece6 100644 --- a/web/components/Input.vue +++ b/web/components/Input.vue @@ -82,7 +82,7 @@ input { width: 100%; padding: 0.5rem 0.75rem; border: 1px solid #d1d5db; - border-radius: 0.375rem; + border-radius: 2px; box-sizing: border-box; /* box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05); */ font-family: var(--font-family); diff --git a/web/components/Panel.vue b/web/components/Panel.vue index 1f2d22e..d417691 100644 --- a/web/components/Panel.vue +++ b/web/components/Panel.vue @@ -4,7 +4,7 @@ import { defineProps, type FunctionalComponent, type PropType } from 'vue'; defineProps({ kind: { - type: String as PropType<"normal" | "error" | "success" | "emphasis">, + type: String as PropType<"normal" | "top" | "error" | "success" | "emphasis">, required: false, default: 'normal', }, @@ -47,14 +47,11 @@ defineProps({ <style> div.card { padding: 1rem; - border-radius: 0.5rem; } div.card-header { padding: 1rem 1rem; margin: -1rem -1rem 0 -1rem; - border-top-left-radius: 0.5rem; - border-top-right-radius: 0.5rem;; display: flex; justify-content: space-between; align-items: center; @@ -73,7 +70,7 @@ div.header-left > svg { } span.card-title { - font-size: 1.5rem; + font-size: 1.3rem; font-weight: 700; } @@ -99,17 +96,21 @@ span.breadcrumb > a:hover { color: var(--color-primary); } -div.normal { +div.normal, div.top { background-color: white; border: 0.1rem solid var(--color-border); } -div.normal .card-header { +div.normal .card-header, div.top .card-header { background-color: var(--color-background-muted); border-bottom: 1px solid var(--color-border); margin: -1rem -1rem 1rem -1rem; } +div.top { + border-top: 3px solid var(--color-primary) +} + div.error { background-color: var(--color-error-light); border: 0.1rem solid var(--color-border-error-light); diff --git a/web/components/Sidebar.vue b/web/components/Sidebar.vue index 5fc42d3..fd64434 100644 --- a/web/components/Sidebar.vue +++ b/web/components/Sidebar.vue @@ -3,6 +3,7 @@ import { formatDistanceToNow } from "date-fns"; import { LucideClock, LucideRadio } from "lucide-vue-next"; const scheduleStore = useScheduleStore(); +const conferenceStore = useConferenceStore(); const errorStore = useErrorStore(); const timer = ref(); @@ -30,31 +31,35 @@ onBeforeUnmount(() => { <template> <div class="sidebar"> - <Panel class="conference"> - <span class="conference-title">{{ scheduleStore.schedule?.conference.title }}</span> - <span class="conference-venue">{{ scheduleStore.schedule?.conference.venue }}</span> - <span class="conference-city">{{ scheduleStore.schedule?.conference.city }}</span> + <Panel class="conference" kind="top"> + <template v-if="conferenceStore.id && conferenceStore.title"> + <span class="conference-title">{{ conferenceStore.title }}</span> + <span class="conference-venue">{{ conferenceStore.venue }}</span> + <span class="conference-city">{{ conferenceStore.city }}</span> + </template> - <Button kind="secondary" @click="errorStore.setError('This doesn\'t do anything yet :-)')">Change conference</Button> + <Button kind="secondary" @click="navigateTo('/conferences')">Change conference</Button> </Panel> - <Panel kind="success" class="ongoing" v-if="ongoing"> - <span>This conference is ongoing</span> - <Button kind="primary" :icon="LucideRadio" @click="navigateTo('/live')">View live</Button> - </Panel> + <template v-if="scheduleStore.schedule != null"> + <Panel kind="success" class="ongoing" v-if="ongoing"> + <span>This conference is ongoing</span> + <Button kind="primary" :icon="LucideRadio" @click="navigateTo('/live')">View live</Button> + </Panel> - <Panel kind="error" class="finished" v-else-if="finished"> - <span>This conference has finished</span> - </Panel> - - <Panel class="upcoming" v-else> - <span class="text-icon"><LucideClock /> <span>Starts in {{ startsIn }}</span></span> - </Panel> - - <Nav /> + <Panel kind="error" class="finished" v-else-if="finished"> + <span>This conference has finished</span> + </Panel> + + <Panel class="upcoming" v-else> + <span class="text-icon"><LucideClock /> <span>Starts in {{ startsIn }}</span></span> + </Panel> + + <Nav /> + </template> <div class="info"> - <span>Times listed are in local time ({{ scheduleStore.schedule?.conference.timeZoneName }})</span> + <span v-if="scheduleStore.schedule != null">Times listed are in local time ({{ scheduleStore.schedule?.conference.timeZoneName }})</span> <Version /> </div> @@ -74,7 +79,7 @@ onBeforeUnmount(() => { align-items: center; gap: 1rem; font-size: var(--text-small); - font-style: oblique; + font-style: italic; text-align: center; } diff --git a/web/composables/api.ts b/web/composables/api.ts new file mode 100644 index 0000000..7881aff --- /dev/null +++ b/web/composables/api.ts @@ -0,0 +1,14 @@ +export function $api<T>( + request: Parameters<typeof $fetch<T>>[0], + opts?: Parameters<typeof $fetch<T>>[1], +) { + const authStore = useAuthStore() + + return $fetch<T>(request, { + ...opts, + headers: { + Authorization: authStore.isLoggedIn() ? `Bearer ${authStore.token}` : '', + ...opts?.headers, + }, + }) +} diff --git a/web/composables/expire-auth.ts b/web/composables/expire-auth.ts new file mode 100644 index 0000000..b0e277e --- /dev/null +++ b/web/composables/expire-auth.ts @@ -0,0 +1,8 @@ +export function expireAuth() { + const authStore = useAuthStore() + + authStore.admin = false; + authStore.username = null; + authStore.token = null; + navigateTo({ path: '/login', state: { error: 'Sorry, your session has expired' } }); +}
\ No newline at end of file diff --git a/web/composables/fetch-favourites.ts b/web/composables/fetch-favourites.ts index e586d5b..965120d 100644 --- a/web/composables/fetch-favourites.ts +++ b/web/composables/fetch-favourites.ts @@ -1,23 +1,21 @@ export default function() { - const favouritesStore = useFavouritesStore(); - const errorStore = useErrorStore(); - const config = useRuntimeConfig(); - - favouritesStore.setStatus('pending') + const conferenceStore = useConferenceStore() + const favouritesStore = useFavouritesStore(); + const errorStore = useErrorStore(); + const config = useRuntimeConfig(); + + favouritesStore.status = 'pending' - useFetch(config.public.baseURL + '/favourites', { - method: 'GET', - server: false, - lazy: true, - onResponseError: ({ response }) => { - favouritesStore.setStatus('idle') + return $api(config.public.baseURL + '/favourites/' + conferenceStore.id, { + method: 'GET', + onResponse: ({ response }) => { + favouritesStore.status = 'idle' + if (!response.ok) { errorStore.setError(response._data.message || 'An unknown error occurred'); - }, - onResponse: ({ response }) => { - if (response._data) { - favouritesStore.setFavourites((response._data as any).data); - } - favouritesStore.setStatus('idle') - }, - }); + } + if (response._data) { + favouritesStore.setFavourites((response._data as any).data); + } + }, + }); }
\ No newline at end of file diff --git a/web/composables/fetch-login.ts b/web/composables/fetch-login.ts index 707a04f..b0b04ba 100644 --- a/web/composables/fetch-login.ts +++ b/web/composables/fetch-login.ts @@ -5,7 +5,7 @@ export default function() { const errorStore = useErrorStore(); const config = useRuntimeConfig(); - loginOptionsStore.setStatus('pending') + loginOptionsStore.status = 'pending' $fetch(config.public.baseURL + '/login', { method: 'GET', @@ -17,8 +17,8 @@ export default function() { } if (response._data) { - loginOptionsStore.setLoginOptions((response._data as any).data.options); - loginOptionsStore.setStatus('idle') + loginOptionsStore.loginOptions = (response._data as any).data.options; + loginOptionsStore.status = 'idle' } }, }); diff --git a/web/composables/fetch-schedule.ts b/web/composables/fetch-schedule.ts index a0e6fec..5e91954 100644 --- a/web/composables/fetch-schedule.ts +++ b/web/composables/fetch-schedule.ts @@ -1,24 +1,39 @@ -export default function() { - const scheduleStore = useScheduleStore(); - const errorStore = useErrorStore(); - const config = useRuntimeConfig(); - - useFetch(config.public.baseURL + '/schedule', { - method: 'GET', - server: false, - lazy: true, - onResponse: ({ response }) => { - if (!response.ok) { - if (response.status === 401) { - navigateTo({ path: '/login', state: { error: 'Sorry, your session has expired' } }); - } else { - errorStore.setError(response._data.message || 'An unknown error occurred'); - } - } +import { useConferenceStore } from "~/stores/conference"; +import { expireAuth } from "./expire-auth"; + +export default async function() { + const conferenceStore = useConferenceStore() + const scheduleStore = useScheduleStore(); + const errorStore = useErrorStore(); + const config = useRuntimeConfig(); - if (response._data) { - scheduleStore.setSchedule((response._data as any).data.schedule); + scheduleStore.status = 'pending' + + return $api(config.public.baseURL + '/conference/' + conferenceStore.id, { + method: 'GET', + onResponse: ({ response }) => { + if (!response.ok) { + if (response.status === 401) { + expireAuth() + return + } else { + errorStore.setError(response._data.message || 'An unknown error occurred'); } - }, - }); + } + + if (response._data) { + let schedule = (response._data as any).data.schedule + scheduleStore.setSchedule(schedule); + + conferenceStore.venue = schedule.conference.venue + conferenceStore.title = schedule.conference.title + conferenceStore.city = schedule.conference.city + + scheduleStore.status = 'idle' + } + }, + }).catch(() => { + // todo do this better + errorStore.setError('An unknown error occurred'); + }); }
\ No newline at end of file diff --git a/web/composables/logout.ts b/web/composables/logout.ts new file mode 100644 index 0000000..35b0511 --- /dev/null +++ b/web/composables/logout.ts @@ -0,0 +1,15 @@ +export function logout() { + const authStore = useAuthStore() + const conferenceStore = useConferenceStore() + const config = useRuntimeConfig(); + + $api(config.public.baseURL + '/logout', { method: 'POST' }).finally(() => { + authStore.admin = false; + authStore.username = null; + authStore.token = null; + + conferenceStore.clear() + + navigateTo({ path: '/login', state: { error: 'You have logged out' } }); + }) +}
\ No newline at end of file diff --git a/web/layouts/default.vue b/web/layouts/default.vue index 2baf5f1..5f85c38 100644 --- a/web/layouts/default.vue +++ b/web/layouts/default.vue @@ -8,7 +8,7 @@ definePageMeta({ middleware: ["logged-in"] }) -const scheduleStore = useScheduleStore(); +const authStore = useAuthStore(); const selectedEventStore = useSelectedEventStore(); const errorStore = useErrorStore(); const router = useRouter(); @@ -21,9 +21,6 @@ const refErrorDialog = ref<typeof Dialog>(); const showHamburger = ref(false); -fetchSchedule(); -fetchFavourites(); - watch(selectedEvent, () => { if (selectedEvent.value != null) { refSelectedDialog.value?.show(); @@ -52,6 +49,7 @@ router.afterEach(() => { <header> <div class="planner-header"> <span class="text-icon planner-title" @click="navigateTo('/')"><BookHeart /> confplanner</span> + <NuxtLink class="logout logout-header" @click="logout">Log out {{ authStore.username }} {{ authStore.admin ? '(admin)' : ''}}</NuxtLink> <span class="hamburger" @click="showHamburger = !showHamburger"> <LucideMenu :size="24" v-if="!showHamburger"/> <LucideX :size="24" v-else /> @@ -59,25 +57,20 @@ router.afterEach(() => { </div> <div class="hamburger-content" v-if="showHamburger"> <Sidebar /> + + <div class="logout-hamburger"> + <NuxtLink class="logout" @click="logout">Log out {{ authStore.username }} {{ authStore.admin ? '(admin)' : ''}}</NuxtLink> + </div> </div> </header> <div class="planner-layout"> - <template v-if="scheduleStore.schedule"> - <aside class="planner-sidebar"> - <Sidebar /> - </aside> + <aside class="planner-sidebar"> + <Sidebar /> + </aside> - <main class="planner-content"> - <slot /> - </main> - </template> - <template v-else> - <div class="loading"> - <span class="loading-text"> - <Spinner color="var(--color-text-muted)" />Updating schedule... - </span> - </div> - </template> + <main class="planner-content"> + <slot /> + </main> </div> </div> @@ -109,7 +102,8 @@ header { div.planner-header { background-color: var(--color-background-muted); color: var(--color-text-muted); - border-bottom: 2px solid var(--color-border); + border-top: 3px solid var(--color-primary); + border-bottom: 1px solid var(--color-border); height: 3.5rem; display: flex; justify-content: space-between; @@ -164,6 +158,15 @@ aside.planner-sidebar { font-size: var(--text-normal); color: var(--color-text-muted); } + +.logout { + cursor: pointer; +} + +.logout-hamburger { + margin-top: 0.5rem; + text-align: right; +} .loading { margin-top: 1rem; @@ -184,6 +187,10 @@ aside.planner-sidebar { flex-direction: column; padding: 0.5rem; } + + .logout-header { + display: none; + } .hamburger { display: block; diff --git a/web/middleware/conference-selected.ts b/web/middleware/conference-selected.ts new file mode 100644 index 0000000..c6415bb --- /dev/null +++ b/web/middleware/conference-selected.ts @@ -0,0 +1,15 @@ +import { useConferenceStore } from "~/stores/conference"; + +const conferenceStore = useConferenceStore(); +const scheduleStore = useScheduleStore() + +export default defineNuxtRouteMiddleware((to, from) => { + if (conferenceStore.id === null) { + return navigateTo("/conferences"); + } + + if (scheduleStore.schedule === null) { + fetchSchedule(); + fetchFavourites(); + } +});
\ No newline at end of file diff --git a/web/middleware/logged-in.ts b/web/middleware/logged-in.ts index 1ddd3ce..97db606 100644 --- a/web/middleware/logged-in.ts +++ b/web/middleware/logged-in.ts @@ -1,21 +1,8 @@ +const authStore = useAuthStore() + export default defineNuxtRouteMiddleware((to, from) => { - if ("" === getCookie("fosdem_planner_session")) { + if (!authStore.isLoggedIn()) { return navigateTo("/login"); } }); -function getCookie(cname: string) { - let name = cname + "="; - let decodedCookie = decodeURIComponent(document.cookie); - let ca = decodedCookie.split(";"); - for (let i = 0; i < ca.length; i++) { - let c = ca[i]; - while (c.charAt(0) == " ") { - c = c.substring(1); - } - if (c.indexOf(name) == 0) { - return c.substring(name.length, c.length); - } - } - return ""; -} diff --git a/web/nuxt.config.ts b/web/nuxt.config.ts index d5762e7..1dfc34e 100644 --- a/web/nuxt.config.ts +++ b/web/nuxt.config.ts @@ -14,7 +14,7 @@ try { export default defineNuxtConfig({ compatibilityDate: "2024-11-01", - devtools: { enabled: true }, + devtools: { enabled: false }, ssr: false, css: ["~/assets/css/main.css"], diff --git a/web/package-lock.json b/web/package-lock.json index e1b6ef0..d034eec 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -10,6 +10,7 @@ "@date-fns/tz": "^1.2.0", "@pinia/nuxt": "^0.9.0", "@vite-pwa/nuxt": "^0.10.6", + "@vueuse/core": "^13.7.0", "date-fns": "^4.1.0", "lucide-vue-next": "^0.471.0", "nuxt": "^3.15.1", @@ -3339,6 +3340,11 @@ "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.21", + "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", + "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==" + }, "node_modules/@unhead/dom": { "version": "1.11.18", "resolved": "https://registry.npmjs.org/@unhead/dom/-/dom-1.11.18.tgz", @@ -3708,6 +3714,41 @@ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.13.tgz", "integrity": "sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==" }, + "node_modules/@vueuse/core": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-13.7.0.tgz", + "integrity": "sha512-myagn09+c6BmS6yHc1gTwwsdZilAovHslMjyykmZH3JNyzI5HoWhv114IIdytXiPipdHJ2gDUx0PB93jRduJYg==", + "dependencies": { + "@types/web-bluetooth": "^0.0.21", + "@vueuse/metadata": "13.7.0", + "@vueuse/shared": "13.7.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, + "node_modules/@vueuse/metadata": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-13.7.0.tgz", + "integrity": "sha512-8okFhS/1ite8EwUdZZfvTYowNTfXmVCOrBFlA31O0HD8HKXhY+WtTRyF0LwbpJfoFPc+s9anNJIXMVrvP7UTZg==", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "13.7.0", + "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-13.7.0.tgz", + "integrity": "sha512-Wi2LpJi4UA9kM0OZ0FCZslACp92HlVNw1KPaDY6RAzvQ+J1s7seOtcOpmkfbD5aBSmMn9NvOakc8ZxMxmDXTIg==", + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + }, "node_modules/abbrev": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", diff --git a/web/package.json b/web/package.json index 50c00ed..a2378f6 100644 --- a/web/package.json +++ b/web/package.json @@ -13,6 +13,7 @@ "@date-fns/tz": "^1.2.0", "@pinia/nuxt": "^0.9.0", "@vite-pwa/nuxt": "^0.10.6", + "@vueuse/core": "^13.7.0", "date-fns": "^4.1.0", "lucide-vue-next": "^0.471.0", "nuxt": "^3.15.1", diff --git a/web/pages/agenda.vue b/web/pages/agenda.vue index 9b55c9b..5126d78 100644 --- a/web/pages/agenda.vue +++ b/web/pages/agenda.vue @@ -8,6 +8,10 @@ const scheduleStore = useScheduleStore(); const errorStore = useErrorStore(); const config = useRuntimeConfig(); +definePageMeta({ + middleware: ['logged-in', 'conference-selected'] +}) + const favouriteEvents = computed(() => { return scheduleStore.events.filter((event) => favouritesStore.isFavourite(event)); }); @@ -19,44 +23,28 @@ const refConfirmDeleteDialog = ref<typeof Dialog>(); const calendarAction = ref(false); -useFetch(config.public.baseURL + '/calendar', { - method: 'GET', - server: false, - lazy: true, - onResponse: ({ response }) => { - if (!response.ok) { - if (response.status !== 404) { - errorStore.setError(response._data.message || 'An unknown error occurred'); - } - } else if (response._data) { - calendarLink.value = (response._data as any).data.url; - } - calendarStatus.value = 'idle'; - }, -}); - function generateCalendar() { calendarAction.value = true; - useFetch(config.public.baseURL + '/calendar', { + $api(config.public.baseURL + '/calendar', { method: 'POST', server: false, lazy: true, - onResponseError: ({ response }) => { - errorStore.setError(response._data.message || 'An unknown error occurred'); - calendarAction.value = false; - }, onResponse: ({ response }) => { + calendarAction.value = false; + if (!response.ok) { + errorStore.setError(response._data.message || 'An unknown error occurred'); + return + } if (response._data) { calendarLink.value = (response._data as any).data.url; } - calendarAction.value = false; }, }); } function deleteCalendar() { calendarAction.value = true; - useFetch(config.public.baseURL + '/calendar', { + $api(config.public.baseURL + '/calendar', { method: 'DELETE', server: false, onResponseError: ({ response }) => { @@ -70,6 +58,24 @@ function deleteCalendar() { }); } +onMounted(() => { + $api(config.public.baseURL + '/calendar', { + method: 'GET', + server: false, + lazy: true, + onResponse: ({ response }) => { + calendarStatus.value = 'idle'; + if (!response.ok) { + if (response.status !== 404) { + errorStore.setError(response._data.message || 'Could not fetch calendar'); + } + } else if (response._data) { + calendarLink.value = (response._data as any).data.url; + } + }, + }); +}) + </script> <template> diff --git a/web/pages/conferences.vue b/web/pages/conferences.vue new file mode 100644 index 0000000..993bc66 --- /dev/null +++ b/web/pages/conferences.vue @@ -0,0 +1,124 @@ +<script setup lang="ts"> +import { MapPinPlus, MapPin } from 'lucide-vue-next'; +import { expireAuth } from '~/composables/expire-auth'; + +definePageMeta({ + middleware: ['logged-in'] +}) + +interface Conference { + id: number, + url: string, + title: string, + venue: string, + city: string, +} + +const setting = ref(false) +const conferences = ref([] as Conference[]) + +const conferenceStore = useConferenceStore(); +const errorStore = useErrorStore(); +const authStore = useAuthStore(); +const config = useRuntimeConfig(); + +const status = ref('idle' as 'loading' | 'idle') + +const fetchConferences = () => { + status.value = 'loading' + $api(config.public.baseURL + '/conference', { + method: 'GET', + onResponse: ({ response }) => { + if (!response.ok) { + if (response.status === 401) { + expireAuth() + return + } + errorStore.setError(response._data.message || 'An unknown error occurred'); + } + status.value = 'idle' + conferences.value = response._data.data + }, + }) +} + +const deleteConference = (id: number) => { + // todo make this better + $api(config.public.baseURL + '/conference', { + method: 'DELETE', + body: { + id: id + }, + onResponse: ({ response }) => { + if (!response.ok) { + errorStore.setError(response._data.message || 'An unknown error occurred'); + } + }, + }) +} + +const selectConference = async (c) => { + setting.value = true + conferenceStore.id = c.id + try { + await fetchSchedule() + await fetchFavourites() + navigateTo({ path: "/events" }) + } catch (e) { + conferenceStore.clear() + setting.value = false + } +} + +onMounted(fetchConferences) +</script> + +<template> + <template v-if="!setting"> + <Panel title="Conferences" :icon="MapPin"> + <span class="loading-text" v-if="status === 'loading'"><Spinner color="var(--color-text-muted)" />Fetching conferences...</span> + <div class="conference-list" v-if="conferences.length > 0 && status !== 'loading'"> + <template v-for="conference of conferences"> + <span class="title">{{ conference.title }}</span> + <span>{{ conference.city }}</span> + <span>{{ conference.venue }}</span> + <span class="actions"> + <Button v-if="authStore.admin" kind="secondary" @click="() => { deleteConference(conference.id) }">Delete</Button> + <Button @click="() => { selectConference(conference) }">Select</Button> + </span> + </template> + </div> + <p v-if="conferences.length == 0 && status !== 'loading'"> + There are no conferences to display. + </p> + </Panel> + + <Panel v-if="authStore.admin" title="Add conference" :icon="MapPinPlus"> + <AddConference @update="fetchConferences" /> + </Panel> + </template> + <template v-else> + <div class="loading"> + <span class="loading-text"><Spinner color="var(--color-text-muted)" />Setting conference...</span> + </div> + </template> +</template> + +<style> +.conference-list { + display: grid; + grid-template-columns: 1fr 1fr 2fr 1fr; + align-items: center; + gap: 0.5rem; +} + +.conference-list > .title { + font-weight: bold; +} + +.conference-list > .actions { + display: flex; + gap: 0.5rem; + justify-self: flex-end; +} +</style>
\ 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(); </script> <template> - <Panel title="Events" :icon="SquareGanttChart" v-if="scheduleStore.schedule"> + <div v-if="scheduleStore.status === 'pending'" class="loading"> + <span class="loading-text"> + <Spinner color="var(--color-text-muted)" />Updating schedule... + </span> + </div> + <Panel title="Events" :icon="SquareGanttChart" v-else> <div v-for="[day, events] of Object.entries(scheduleStore.eventsPerDay)" :key="day" class="events-container"> <ul class="events-list"> <li v-for="event in events" :key="event.id" class="event-item" :data-index="event.id"> diff --git a/web/pages/index.vue b/web/pages/index.vue index c455678..e662bc7 100644 --- a/web/pages/index.vue +++ b/web/pages/index.vue @@ -1,4 +1,8 @@ <script setup lang="ts"> +definePageMeta({ + middleware: ['logged-in', 'conference-selected'] +}) + const scheduleStore = useScheduleStore(); const destination = ref() @@ -13,6 +17,11 @@ if (scheduleStore.isConferenceOngoing()) { </script> <template> + <div v-if="scheduleStore.status === 'pending'" class="loading"> + <span class="loading-text"> + <Spinner color="var(--color-text-muted)" />Updating schedule... + </span> + </div> <Panel kind="success"> <span class="text-icon"> <Spinner /> diff --git a/web/pages/live.vue b/web/pages/live.vue index d69dce5..638910e 100644 --- a/web/pages/live.vue +++ b/web/pages/live.vue @@ -4,6 +4,10 @@ import EventListing from '~/components/EventListing.vue'; import Panel from '~/components/Panel.vue'; import { type Event } from '~/stores/schedule'; +definePageMeta({ + middleware: ['logged-in', 'conference-selected'] +}) + const favouritesStore = useFavouritesStore(); const scheduleStore = useScheduleStore(); @@ -56,6 +60,11 @@ function isToday(date: Date): boolean { </script> <template> + <div v-if="scheduleStore.status === 'pending'" class="loading"> + <span class="loading-text"> + <Spinner color="var(--color-text-muted)" />Updating schedule... + </span> + </div> <Panel kind="emphasis" class="ongoing" v-if="happeningNow.length > 0" title="Now" :icon="Radio"> <ul class="events-list"> <li v-for="event in showAllHappeningNow ? happeningNow : favouritesHappeningNow" :key="event.id"> diff --git a/web/pages/login/[[provider]].vue b/web/pages/login/[[provider]].vue index bfc7e69..58cf2b1 100644 --- a/web/pages/login/[[provider]].vue +++ b/web/pages/login/[[provider]].vue @@ -16,6 +16,7 @@ const basicAuthEnabled = ref(false) const route = useRoute() const config = useRuntimeConfig() +const authStore = useAuthStore() const loginOptionsStore = useLoginOptionsStore() const headers = useRequestHeaders(['cookie']) @@ -25,6 +26,17 @@ watch(loginOptions, (options) => { basicAuthEnabled.value = options.some(o => o.type === 'basic') }) +const authFail = (e: any) => { + if ((e as FetchError).data) { + error.value = e.data.message + } else { + error.value = "An unknown error occurred" + } + + authenticating.value = false + authenticatingProvider.value = '' +} + const handleBasicAuth = async (e: Event, providerName: string) => { const target = e.target as HTMLFormElement; const formData = new FormData(target); @@ -32,48 +44,43 @@ const handleBasicAuth = async (e: Event, providerName: string) => { authenticating.value = true authenticatingProvider.value = providerName - try { - await $fetch(config.public.baseURL + '/login/' + providerName, { - method: 'POST', - body: JSON.stringify(Object.fromEntries(formData)), - headers: headers, - server: false, - }); - - navigateTo("/"); - } catch (e: any) { - if ((e as FetchError).data) { - error.value = e.data.message - } else { - error.value = "An unknown error occurred" - } + $fetch(config.public.baseURL + '/login/' + providerName, { + method: 'POST', + body: JSON.stringify(Object.fromEntries(formData)), + headers: headers, + server: false, + onResponse: ({ response }) => { + authStore.token = response._data.data.token + authStore.username = response._data.data.username + authStore.admin = response._data.data.admin - authenticating.value = false - authenticatingProvider.value = '' - } + navigateTo("/"); + }, + onResponseError: authFail + }); + } const handleOIDCAuth = async (providerName: string) => { authenticating.value = true authenticatingProvider.value = providerName - try { - let response: any = await $fetch(config.public.baseURL + '/login/' + providerName, { - method: 'POST', - headers: headers, - server: false, - }); - navigateTo(response.data.url, { external: true }) - } catch (e: any) { - if ((e as FetchError).data) { - error.value = e.data.message - } else { - error.value = "An unknown error occurred" - } + $fetch(config.public.baseURL + '/login/' + providerName, { + method: 'POST', + headers: headers, + server: false, + onResponse: ({ response }) => { + if (response._data.data.url) { + navigateTo(response._data.data.url, { external: true }) + } else { + authStore.token = response._data.data.token + authStore.admin = response._data.data.admin - authenticating.value = false - authenticatingProvider.value = '' - } + navigateTo("/"); + } + }, + onResponseError: authFail + }); } onMounted(async () => { @@ -230,30 +237,9 @@ div.auth-form { gap: 1.5rem; } -form.basic-form { - display: grid; - gap: 1.5rem; -} - div.auth-error { color: var(--color-text-error); - font-style: oblique; -} - -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; + font-style: italic; } div.form-footer { diff --git a/web/pages/tracks/[slug].vue b/web/pages/tracks/[slug].vue index 27fb97d..9e1881d 100644 --- a/web/pages/tracks/[slug].vue +++ b/web/pages/tracks/[slug].vue @@ -2,6 +2,10 @@ import { TrainTrack } from 'lucide-vue-next'; import { useScheduleStore } from '~/stores/schedule'; +definePageMeta({ + middleware: ['logged-in', 'conference-selected'] +}) + const route = useRoute(); const scheduleStore = useScheduleStore(); @@ -9,6 +13,11 @@ const track = scheduleStore.schedule?.tracks.find((track) => track.slug === rout </script> <template> + <div v-if="scheduleStore.status === 'pending'" class="loading"> + <span class="loading-text"> + <Spinner color="var(--color-text-muted)" />Updating schedule... + </span> + </div> <Panel v-if="track" :title="track.name" :breadcrumbs="[{ text: 'Tracks', to: '/tracks' }]" :icon="TrainTrack"> <ul class="events-list"> <li diff --git a/web/pages/tracks/index.vue b/web/pages/tracks/index.vue index 8d7534e..c3ec883 100644 --- a/web/pages/tracks/index.vue +++ b/web/pages/tracks/index.vue @@ -2,14 +2,22 @@ import { TrainTrack } from 'lucide-vue-next'; import Panel from '~/components/Panel.vue'; +definePageMeta({ + middleware: ['logged-in', 'conference-selected'] +}) const scheduleStore = useScheduleStore(); </script> <template> - <Panel v-if="scheduleStore.schedule" title="Tracks" :icon="TrainTrack"> + <div v-if="scheduleStore.status === 'pending'" class="loading"> + <span class="loading-text"> + <Spinner color="var(--color-text-muted)" />Updating schedule... + </span> + </div> + <Panel v-else title="Tracks" :icon="TrainTrack"> <ul class="tracks-list"> <li - v-for="track in scheduleStore.schedule.tracks" + v-for="track in scheduleStore.schedule?.tracks" :key="track.name" class="tracks-item" > diff --git a/web/stores/auth.ts b/web/stores/auth.ts new file mode 100644 index 0000000..71ba5c0 --- /dev/null +++ b/web/stores/auth.ts @@ -0,0 +1,12 @@ +import { useLocalStorage } from "@vueuse/core"; +import { defineStore } from "pinia"; + +export const useAuthStore = defineStore('auth', () => { + const token = useLocalStorage('auth/token', null) + const username = useLocalStorage('auth/username', null) + const admin = useLocalStorage('auth/admin', false) + + const isLoggedIn = () => token.value != null + + return {token, username, admin, isLoggedIn} +}) diff --git a/web/stores/conference.ts b/web/stores/conference.ts new file mode 100644 index 0000000..2dbe82a --- /dev/null +++ b/web/stores/conference.ts @@ -0,0 +1,18 @@ +import { useLocalStorage } from "@vueuse/core"; +import { defineStore } from "pinia"; + +export const useConferenceStore = defineStore('conference', () => { + const id = useLocalStorage('conference/id', null) + const title = useLocalStorage('conference/title', null) + const venue = useLocalStorage('conference/venue', null) + const city = useLocalStorage('conference/city', null) + + const clear = () => { + id.value = null + title.value = null + venue.value = null + city.value = null + } + + return {id, title, venue, city, clear} +}) diff --git a/web/stores/favourites.ts b/web/stores/favourites.ts index 2bf7257..d502788 100644 --- a/web/stores/favourites.ts +++ b/web/stores/favourites.ts @@ -35,9 +35,5 @@ export const useFavouritesStore = defineStore('favourites', () => { }) } - const setStatus = (newStatus: 'idle' | 'pending') => { - status.value = newStatus - } - - return {favourites, status, setFavourites, addFavourite, removeFavourite, isFavourite, setStatus} + return {favourites, status, setFavourites, addFavourite, removeFavourite, isFavourite} }) diff --git a/web/stores/login-options.ts b/web/stores/login-options.ts index fd97a75..d66c833 100644 --- a/web/stores/login-options.ts +++ b/web/stores/login-options.ts @@ -10,13 +10,5 @@ export const useLoginOptionsStore = defineStore('loginOptions', () => { const loginOptions = ref([] as LoginOption[]) const status = ref('idle' as 'idle' | 'pending') - const setLoginOptions = (newLoginOptions: LoginOption[]) => { - loginOptions.value = newLoginOptions - } - - const setStatus = (newStatus: 'idle' | 'pending') => { - status.value = newStatus - } - - return {loginOptions, status, setLoginOptions, setStatus} + return {loginOptions, status} }) diff --git a/web/stores/schedule.ts b/web/stores/schedule.ts index d5f4b4c..83f274d 100644 --- a/web/stores/schedule.ts +++ b/web/stores/schedule.ts @@ -74,6 +74,7 @@ interface Link { export const useScheduleStore = defineStore('schedule', () => { const schedule = ref(null as Schedule | null) + const status = ref('idle' as 'idle' | 'pending') const events = ref([] as Event[]) const eventsPerDay = ref({} as { [key: string]: Event[] }) @@ -101,7 +102,9 @@ export const useScheduleStore = defineStore('schedule', () => { events.value.push(event) - event.track = tracks.value[event.track as unknown as string] + if (event.track) { + event.track = tracks.value[event.track as unknown as string] + } }) }) }) @@ -122,6 +125,7 @@ export const useScheduleStore = defineStore('schedule', () => { eventsPerTrack.value = {} events.value.forEach(event => { + if (!event.track) return if (!eventsPerTrack.value[event.track.name]) { eventsPerTrack.value[event.track.name] = [] } @@ -147,7 +151,7 @@ export const useScheduleStore = defineStore('schedule', () => { return schedule.value?.conference.start || 0 } - return {schedule, events, eventsPerDay, eventsPerTrack, setSchedule, isConferenceOngoing, isConferenceFinished, getStartDate} + return {schedule, events, eventsPerDay, eventsPerTrack, status, setSchedule, isConferenceOngoing, isConferenceFinished, getStartDate} }) function normalizeDates(event: Event, timeZone: string) { |
