diff options
| author | Leonardo Bishop <me@leonardobishop.com> | 2025-01-20 02:56:25 +0000 |
|---|---|---|
| committer | Leonardo Bishop <me@leonardobishop.com> | 2025-01-20 02:56:25 +0000 |
| commit | dc55f9c0097e1c36b85d7666071b840b902920e9 (patch) | |
| tree | c8c8ae10a9e134810b3361aabc8a9d426d813808 | |
| parent | 5e7ce6cbae81a1b6e46fe6738dc10039a06bec95 (diff) | |
Add calendar support
| -rw-r--r-- | api/dto/calendar.go | 15 | ||||
| -rw-r--r-- | api/handlers/calendar.go | 81 | ||||
| -rw-r--r-- | api/handlers/ical.go | 43 | ||||
| -rw-r--r-- | api/handlers/schedule.go | 2 | ||||
| -rw-r--r-- | api/router.go | 11 | ||||
| -rw-r--r-- | go.mod | 3 | ||||
| -rw-r--r-- | go.sum | 6 | ||||
| -rw-r--r-- | internal/config.go | 3 | ||||
| -rw-r--r-- | main.go | 8 | ||||
| -rw-r--r-- | pkg/calendar/service.go | 112 | ||||
| -rw-r--r-- | pkg/database/migrations/0001_initial.sql | 17 | ||||
| -rw-r--r-- | pkg/database/query/calendars.sql | 23 | ||||
| -rw-r--r-- | pkg/database/query/favourites.sql | 2 | ||||
| -rw-r--r-- | pkg/database/sqlc/calendars.sql.go | 97 | ||||
| -rw-r--r-- | pkg/database/sqlc/favourites.sql.go | 36 | ||||
| -rw-r--r-- | pkg/database/sqlc/models.go | 7 | ||||
| -rw-r--r-- | pkg/ical/service.go | 78 | ||||
| -rw-r--r-- | pkg/schedule/model.go | 72 | ||||
| -rw-r--r-- | pkg/schedule/parse.go | 205 | ||||
| -rw-r--r-- | pkg/schedule/service.go | 140 |
20 files changed, 831 insertions, 130 deletions
diff --git a/api/dto/calendar.go b/api/dto/calendar.go new file mode 100644 index 0000000..33da621 --- /dev/null +++ b/api/dto/calendar.go @@ -0,0 +1,15 @@ +package dto + +type GetCalendarResponse struct { + ID int32 `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + URL string `json:"url"` +} + +type CreateCalendarResponse struct { + ID int32 `json:"id"` + Name string `json:"name"` + Key string `json:"key"` + URL string `json:"url"` +} diff --git a/api/handlers/calendar.go b/api/handlers/calendar.go new file mode 100644 index 0000000..85a9b00 --- /dev/null +++ b/api/handlers/calendar.go @@ -0,0 +1,81 @@ +package handlers + +import ( + "errors" + + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/calendar" + "github.com/gofiber/fiber/v2" +) + +func GetCalendar(calendarService calendar.Service, baseURL string) fiber.Handler { + // TODO create config service + return func(c *fiber.Ctx) error { + uid := c.Locals("uid").(int32) + + cal, err := calendarService.GetCalendarForUser(uid) + if err != nil { + if errors.Is(err, calendar.ErrCalendarNotFound) { + return &dto.ErrorResponse{ + Code: fiber.StatusNotFound, + Message: "Calendar not found", + } + } + + return err + } + + return &dto.OkResponse{ + Code: fiber.StatusOK, + Data: &dto.GetCalendarResponse{ + ID: cal.ID, + Name: cal.Name, + Key: cal.Key, + URL: baseURL + "/calendar/ical?name=" + cal.Name + "&key=" + cal.Key, + }, + } + } +} + +func CreateCalendar(calendarService calendar.Service, baseURL string) fiber.Handler { + return func(c *fiber.Ctx) error { + uid := c.Locals("uid").(int32) + + cal, err := calendarService.CreateCalendarForUser(uid) + if err != nil { + return err + } + + return &dto.OkResponse{ + Code: fiber.StatusCreated, + Data: &dto.CreateCalendarResponse{ + ID: cal.ID, + Name: cal.Name, + Key: cal.Key, + URL: baseURL + "/calendar/ical?name=" + cal.Name + "&key=" + cal.Key, + }, + } + } +} + +func DeleteCalendar(calendarService calendar.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + uid := c.Locals("uid").(int32) + + err := calendarService.DeleteCalendarForUser(uid) + if err != nil { + if errors.Is(err, calendar.ErrCalendarNotFound) { + return &dto.ErrorResponse{ + Code: fiber.StatusNotFound, + Message: "Calendar not found", + } + } + + return err + } + + return &dto.OkResponse{ + Code: fiber.StatusOK, + } + } +} diff --git a/api/handlers/ical.go b/api/handlers/ical.go new file mode 100644 index 0000000..c4b3989 --- /dev/null +++ b/api/handlers/ical.go @@ -0,0 +1,43 @@ +package handlers + +import ( + "crypto/subtle" + + "github.com/LMBishop/confplanner/api/dto" + "github.com/LMBishop/confplanner/pkg/calendar" + "github.com/LMBishop/confplanner/pkg/ical" + "github.com/gofiber/fiber/v2" +) + +func GetIcal(icalService ical.Service, calendarService calendar.Service) fiber.Handler { + return func(c *fiber.Ctx) error { + name := c.Query("name") + key := c.Query("key") + + if name == "" || key == "" { + return &dto.ErrorResponse{ + Code: fiber.StatusBadRequest, + Message: "Both name and key must be specified", + } + } + + calendar, err := calendarService.GetCalendarByName(name) + if err != nil { + return err + } + + if subtle.ConstantTimeCompare([]byte(key), []byte(calendar.Key)) != 1 { + return &dto.ErrorResponse{ + Code: fiber.StatusUnauthorized, + Message: "Invalid key", + } + } + + ical, err := icalService.GenerateIcalForCalendar(*calendar) + if err != nil { + return err + } + + return c.SendString(ical) + } +} diff --git a/api/handlers/schedule.go b/api/handlers/schedule.go index fd3a183..46589ab 100644 --- a/api/handlers/schedule.go +++ b/api/handlers/schedule.go @@ -18,7 +18,7 @@ func GetSchedule(service schedule.Service) fiber.Handler { Code: fiber.StatusOK, Data: &dto.GetScheduleResponse{ Schedule: nilslice.Initialize(*schedule), - LastUpdated: *lastUpdated, + LastUpdated: lastUpdated, }, } } diff --git a/api/router.go b/api/router.go index 82a29b1..dc7e487 100644 --- a/api/router.go +++ b/api/router.go @@ -8,7 +8,9 @@ import ( "github.com/LMBishop/confplanner/api/dto" "github.com/LMBishop/confplanner/api/handlers" "github.com/LMBishop/confplanner/api/middleware" + "github.com/LMBishop/confplanner/pkg/calendar" "github.com/LMBishop/confplanner/pkg/favourites" + "github.com/LMBishop/confplanner/pkg/ical" "github.com/LMBishop/confplanner/pkg/schedule" "github.com/LMBishop/confplanner/pkg/user" "github.com/gofiber/fiber/v2" @@ -19,9 +21,11 @@ type ApiServices struct { UserService user.Service FavouritesService favourites.Service ScheduleService schedule.Service + CalendarService calendar.Service + IcalService ical.Service } -func NewServer(apiServices ApiServices) *fiber.App { +func NewServer(apiServices ApiServices, baseURL string) *fiber.App { sessionStore := session.New(session.Config{ Expiration: 24 * time.Hour, KeyLookup: "cookie:confplanner_session", @@ -63,5 +67,10 @@ func NewServer(apiServices ApiServices) *fiber.App { app.Get("/schedule", requireAuthenticated, handlers.GetSchedule(apiServices.ScheduleService)) + app.Get("/calendar", requireAuthenticated, handlers.GetCalendar(apiServices.CalendarService, baseURL)) + app.Post("/calendar", requireAuthenticated, handlers.CreateCalendar(apiServices.CalendarService, baseURL)) + app.Delete("/calendar", requireAuthenticated, handlers.DeleteCalendar(apiServices.CalendarService)) + app.Use("/calendar/ical", handlers.GetIcal(apiServices.IcalService, apiServices.CalendarService)) + return app } @@ -4,6 +4,7 @@ go 1.23.4 require ( github.com/andybalholm/brotli v1.1.1 // indirect + github.com/aymerick/douceur v0.2.0 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -11,6 +12,7 @@ require ( github.com/gofiber/fiber/v2 v2.52.6 // indirect github.com/golang-cz/nilslice v0.0.0-20240305001642-646f70fbdbf7 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gorilla/css v1.0.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx v3.6.2+incompatible // indirect @@ -22,6 +24,7 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mfridman/interpolate v0.0.2 // indirect + github.com/microcosm-cc/bluemonday v1.0.27 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pressly/goose/v3 v3.24.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect @@ -2,6 +2,8 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= +github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= @@ -17,6 +19,8 @@ github.com/golang-cz/nilslice v0.0.0-20240305001642-646f70fbdbf7 h1:VJnCioFIl+oq github.com/golang-cz/nilslice v0.0.0-20240305001642-646f70fbdbf7/go.mod h1:zKbg8dCJWqvE0zHOhHXPHFpqpABjoK3MDzWo+Pi1O7k= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= +github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -40,6 +44,8 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= +github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= diff --git a/internal/config.go b/internal/config.go index 9a1bc5f..564adbe 100644 --- a/internal/config.go +++ b/internal/config.go @@ -17,7 +17,8 @@ type Config struct { Conference struct { ScheduleURL string `yaml:"scheduleURL"` } `yaml:"conference"` - AcceptRegistrations bool `yaml:"acceptRegistrations"` + AcceptRegistrations bool `yaml:"acceptRegistrations"` + BaseURL string `yaml:"baseURL"` } func ReadConfig(configPath string, dst *Config) error { @@ -7,8 +7,10 @@ import ( "github.com/LMBishop/confplanner/api" config "github.com/LMBishop/confplanner/internal" + "github.com/LMBishop/confplanner/pkg/calendar" "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/user" ) @@ -42,12 +44,16 @@ func run() error { if err != nil { return fmt.Errorf("failed to create schedule service: %w", err) } + calendarService := calendar.NewService(pool) + icalService := ical.NewService(favouritesService, scheduleService) app := api.NewServer(api.ApiServices{ UserService: userService, FavouritesService: favouritesService, ScheduleService: scheduleService, - }) + CalendarService: calendarService, + IcalService: icalService, + }, c.BaseURL) slog.Info("Server is listening", "host", c.Server.Host, "port", c.Server.Port) diff --git a/pkg/calendar/service.go b/pkg/calendar/service.go new file mode 100644 index 0000000..19b2d9d --- /dev/null +++ b/pkg/calendar/service.go @@ -0,0 +1,112 @@ +package calendar + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "math/big" + + "github.com/LMBishop/confplanner/pkg/database/sqlc" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Service interface { + GetCalendarForUser(id int32) (*sqlc.Calendar, error) + GetCalendarByName(name string) (*sqlc.Calendar, error) + CreateCalendarForUser(id int32) (*sqlc.Calendar, error) + DeleteCalendarForUser(id int32) error +} + +var ( + ErrCalendarNotFound = errors.New("calendar not found") +) + +type service struct { + pool *pgxpool.Pool +} + +func NewService(pool *pgxpool.Pool) Service { + return &service{ + pool: pool, + } +} + +func (s *service) GetCalendarForUser(id int32) (*sqlc.Calendar, error) { + queries := sqlc.New(s.pool) + + calendar, err := queries.GetCalendarForUser(context.Background(), id) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrCalendarNotFound + } + return nil, err + } + + return &calendar, nil +} + +func (s *service) GetCalendarByName(name string) (*sqlc.Calendar, error) { + queries := sqlc.New(s.pool) + + calendar, err := queries.GetCalendarByName(context.Background(), name) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrCalendarNotFound + } + return nil, err + } + + return &calendar, nil +} + +func (s *service) CreateCalendarForUser(id int32) (*sqlc.Calendar, error) { + queries := sqlc.New(s.pool) + + name, err := randomString(16) + if err != nil { + return nil, fmt.Errorf("could not generate random string: %w", err) + } + + key, err := randomString(32) + if err != nil { + return nil, fmt.Errorf("could not generate random string: %w", err) + } + + calendar, err := queries.CreateCalendar(context.Background(), sqlc.CreateCalendarParams{ + UserID: id, + Name: name, + Key: key, + }) + if err != nil { + return nil, fmt.Errorf("could not create calendar: %w", err) + } + + return &calendar, nil +} + +func (s *service) DeleteCalendarForUser(id int32) error { + queries := sqlc.New(s.pool) + + _, err := queries.DeleteCalendar(context.Background(), id) + if err != nil { + return fmt.Errorf("could not delete calendar: %w", err) + } + + return nil +} + +func randomString(n int) (string, error) { + const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + ret := make([]byte, n) + for i := 0; i < n; i++ { + num, err := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) + if err != nil { + return "", err + } + ret[i] = letters[num.Int64()] + } + + return string(ret), nil +} diff --git a/pkg/database/migrations/0001_initial.sql b/pkg/database/migrations/0001_initial.sql index eea0a73..a1f5fda 100644 --- a/pkg/database/migrations/0001_initial.sql +++ b/pkg/database/migrations/0001_initial.sql @@ -1,8 +1,8 @@ -- +goose Up CREATE TABLE users ( id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - username varchar(20) UNIQUE NOT NULL, - password char(60) NOT NULL + username varchar(20) UNIQUE NOT NULL CONSTRAINT non_blank_username CHECK(length(username) > 0), + password text NOT NULL CONSTRAINT valid_hash CHECK (length(password) = 60) ); CREATE TABLE favourites ( @@ -11,7 +11,16 @@ CREATE TABLE favourites ( event_guid uuid, event_id int, UNIQUE(user_id, event_guid, event_id), - CONSTRAINT chk_favourites CHECK (event_guid IS NOT NULL OR event_id IS NOT NULL), - FOREIGN KEY (user_id) REFERENCES users(id) + CONSTRAINT require_event_detail CHECK (event_guid IS NOT NULL OR event_id IS NOT NULL), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE calendars ( + id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + user_id int NOT NULL, + name text NOT NULL CONSTRAINT non_blank_name CHECK(length(name) > 0), + key text NOT NULL, + UNIQUE(user_id), + UNIQUE(name), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); diff --git a/pkg/database/query/calendars.sql b/pkg/database/query/calendars.sql new file mode 100644 index 0000000..490475d --- /dev/null +++ b/pkg/database/query/calendars.sql @@ -0,0 +1,23 @@ +-- name: CreateCalendar :one +INSERT INTO calendars ( + user_id, name, key +) VALUES ( + $1, $2, $3 +) +RETURNING *; + +-- name: GetCalendarForUser :one +SELECT * FROM calendars +WHERE user_id = $1 LIMIT 1; + +-- name: GetCalendarByName :one +SELECT * FROM calendars +WHERE name = $1 LIMIT 1; + +-- name: DeleteCalendar :execrows +DELETE FROM calendars +WHERE user_id = $1; + +-- name: DeleteCalendarByName :execrows +DELETE FROM calendars +WHERE name = $1; diff --git a/pkg/database/query/favourites.sql b/pkg/database/query/favourites.sql index 0661daa..94e914c 100644 --- a/pkg/database/query/favourites.sql +++ b/pkg/database/query/favourites.sql @@ -16,4 +16,4 @@ WHERE id = $1; -- name: DeleteFavouriteByEventDetails :execrows DELETE FROM favourites -WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3;
\ No newline at end of file +WHERE (event_guid = $1 OR event_id = $2) AND user_id = $3; diff --git a/pkg/database/sqlc/calendars.sql.go b/pkg/database/sqlc/calendars.sql.go new file mode 100644 index 0000000..47ae37f --- /dev/null +++ b/pkg/database/sqlc/calendars.sql.go @@ -0,0 +1,97 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.27.0 +// source: calendars.sql + +package sqlc + +import ( + "context" +) + +const createCalendar = `-- name: CreateCalendar :one +INSERT INTO calendars ( + user_id, name, key +) VALUES ( + $1, $2, $3 +) +RETURNING id, user_id, name, key +` + +type CreateCalendarParams struct { + UserID int32 `json:"user_id"` + Name string `json:"name"` + Key string `json:"key"` +} + +func (q *Queries) CreateCalendar(ctx context.Context, arg CreateCalendarParams) (Calendar, error) { + row := q.db.QueryRow(ctx, createCalendar, arg.UserID, arg.Name, arg.Key) + var i Calendar + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Key, + ) + return i, err +} + +const deleteCalendar = `-- name: DeleteCalendar :execrows +DELETE FROM calendars +WHERE user_id = $1 +` + +func (q *Queries) DeleteCalendar(ctx context.Context, userID int32) (int64, error) { + result, err := q.db.Exec(ctx, deleteCalendar, userID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const deleteCalendarByName = `-- name: DeleteCalendarByName :execrows +DELETE FROM calendars +WHERE name = $1 +` + +func (q *Queries) DeleteCalendarByName(ctx context.Context, name string) (int64, error) { + result, err := q.db.Exec(ctx, deleteCalendarByName, name) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const getCalendarByName = `-- name: GetCalendarByName :one +SELECT id, user_id, name, key FROM calendars +WHERE name = $1 LIMIT 1 +` + +func (q *Queries) GetCalendarByName(ctx context.Context, name string) (Calendar, error) { + row := q.db.QueryRow(ctx, getCalendarByName, name) + var i Calendar + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Key, + ) + return i, err +} + +const getCalendarForUser = `-- name: GetCalendarForUser :one +SELECT id, user_id, name, key FROM calendars +WHERE user_id = $1 LIMIT 1 +` + +func (q *Queries) GetCalendarForUser(ctx context.Context, userID int32) (Calendar, error) { + row := q.db.QueryRow(ctx, getCalendarForUser, userID) + var i Calendar + err := row.Scan( + &i.ID, + &i.UserID, + &i.Name, + &i.Key, + ) + return i, err +} diff --git a/pkg/database/sqlc/favourites.sql.go b/pkg/database/sqlc/favourites.sql.go index 3bf7c06..359ae9d 100644 --- a/pkg/database/sqlc/favourites.sql.go +++ b/pkg/database/sqlc/favourites.sql.go @@ -67,42 +67,6 @@ func (q *Queries) DeleteFavouriteByEventDetails(ctx context.Context, arg DeleteF return result.RowsAffected(), nil } -const deleteFavouriteByEventGuid = `-- name: DeleteFavouriteByEventGuid :execrows -DELETE FROM favourites -WHERE event_guid = $1 AND user_id = $2 -` - -type DeleteFavouriteByEventGuidParams struct { - EventGuid pgtype.UUID `json:"event_guid"` - UserID int32 `json:"user_id"` -} - -func (q *Queries) DeleteFavouriteByEventGuid(ctx context.Context, arg DeleteFavouriteByEventGuidParams) (int64, error) { - result, err := q.db.Exec(ctx, deleteFavouriteByEventGuid, arg.EventGuid, arg.UserID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - -const deleteFavouriteByEventId = `-- name: DeleteFavouriteByEventId :execrows -DELETE FROM favourites -WHERE event_id = $1 AND user_id = $2 -` - -type DeleteFavouriteByEventIdParams struct { - EventID pgtype.Int4 `json:"event_id"` - UserID int32 `json:"user_id"` -} - -func (q *Queries) DeleteFavouriteByEventId(ctx context.Context, arg DeleteFavouriteByEventIdParams) (int64, error) { - result, err := q.db.Exec(ctx, deleteFavouriteByEventId, arg.EventID, arg.UserID) - if err != nil { - return 0, err - } - return result.RowsAffected(), nil -} - const getFavouritesForUser = `-- name: GetFavouritesForUser :many SELECT id, user_id, event_guid, event_id FROM favourites WHERE user_id = $1 diff --git a/pkg/database/sqlc/models.go b/pkg/database/sqlc/models.go index 09208aa..e38851a 100644 --- a/pkg/database/sqlc/models.go +++ b/pkg/database/sqlc/models.go @@ -8,6 +8,13 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +type Calendar struct { + ID int32 `json:"id"` + UserID int32 `json:"user_id"` + Name string `json:"name"` + Key string `json:"key"` +} + type Favourite struct { ID int32 `json:"id"` UserID int32 `json:"user_id"` diff --git a/pkg/ical/service.go b/pkg/ical/service.go new file mode 100644 index 0000000..2b89fe7 --- /dev/null +++ b/pkg/ical/service.go @@ -0,0 +1,78 @@ +package ical + +import ( + "errors" + "strings" + "time" + + "github.com/LMBishop/confplanner/pkg/database/sqlc" + "github.com/LMBishop/confplanner/pkg/favourites" + "github.com/LMBishop/confplanner/pkg/schedule" + "github.com/microcosm-cc/bluemonday" +) + +type Service interface { + GenerateIcalForCalendar(calendar sqlc.Calendar) (string, error) +} + +var ( + ErrImproperType = errors.New("improper type") + ErrNotFound = errors.New("not found") +) + +type service struct { + favouritesService favourites.Service + scheduleService schedule.Service +} + +func NewService( + favouritesService favourites.Service, + scheduleService schedule.Service, +) Service { + return &service{ + favouritesService: favouritesService, + scheduleService: scheduleService, + } +} + +func (s *service) GenerateIcalForCalendar(calendar sqlc.Calendar) (string, error) { + favourites, err := s.favouritesService.GetFavouritesForUser(calendar.UserID) + if err != nil { + return "", err + } + + sched, _, err := s.scheduleService.GetSchedule() + if err != nil { + return "", err + } + + events := make([]schedule.Event, 0) + for _, favourite := range *favourites { + event := s.scheduleService.GetEventByID(favourite.EventID.Int32) + events = append(events, *event) + } + + now := time.Now() + + // https://www.rfc-editor.org/rfc/rfc5545.html + + ret := "BEGIN:VCALENDAR\n" + ret += "VERSION:2.0\n" + ret += "METHOD:PUBLISH\n" + ret += "X-WR-CALNAME:confplanner calendar\n" + for _, event := range events { + utcStart := event.Start.UTC() + utcEnd := event.End.UTC() + + ret += "BEGIN:VEVENT\n" + ret += "SUMMARY:" + event.Title + "\n" + ret += "DTSTART;TZID=" + sched.Conference.TimeZoneName + ":" + utcStart.Format("20060102T150405Z") + "\n" + ret += "DTEND;TZID=" + sched.Conference.TimeZoneName + ":" + utcEnd.Format("20060102T150405Z") + "\n" + ret += "LOCATION:" + event.Room + "\n" + ret += "DESCRIPTION;ENCODING=QUOTED-PRINTABLE:" + bluemonday.StrictPolicy().Sanitize(strings.Replace(event.Abstract, "\n", "\\n", -1)) + "\\nLast Synchronised: " + now.Format(time.DateTime) + "\n" + ret += "END:VEVENT\n" + } + ret += "END:VCALENDAR\n" + + return ret, nil +} diff --git a/pkg/schedule/model.go b/pkg/schedule/model.go new file mode 100644 index 0000000..fcf39a5 --- /dev/null +++ b/pkg/schedule/model.go @@ -0,0 +1,72 @@ +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 new file mode 100644 index 0000000..b0ed8df --- /dev/null +++ b/pkg/schedule/parse.go @@ -0,0 +1,205 @@ +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 index 70587ef..a73a469 100644 --- a/pkg/schedule/service.go +++ b/pkg/schedule/service.go @@ -10,85 +10,22 @@ import ( ) type Service interface { - GetSchedule() (*Schedule, *time.Time, error) -} - -type Schedule struct { - XMLName xml.Name `json:"-" xml:"schedule"` - Conference Conference `json:"conference" xml:"conference"` - Tracks []Track `json:"tracks" xml:"tracks>track"` - Days []Day `json:"days" xml:"day"` -} - -type Conference struct { - Title string `json:"title" xml:"title"` - Venue string `json:"venue" xml:"venue"` - City string `json:"city" xml:"city"` - Start string `json:"start" xml:"start"` - End string `json:"end" xml:"end"` - Days int `json:"days" xml:"days"` - DayChange string `json:"dayChange" xml:"day_change"` - TimeslotDuration string `json:"timeslotDuration" xml:"timeslot_duration"` - BaseURL string `json:"baseUrl" xml:"base_url"` - TimeZoneName string `json:"timeZoneName" xml:"time_zone_name"` -} - -type Track struct { - Name string `json:"name" xml:",chardata"` -} - -type Day struct { - Date string `json:"date" xml:"date,attr"` - Start string `json:"start" xml:"start"` - End string `json:"end" xml:"end"` - Rooms []Room `json:"rooms" xml:"room"` -} - -type Room struct { - Name string `json:"name" xml:"name,attr"` - Events []Event `json:"events" xml:"event"` -} - -type Event struct { - ID int `json:"id" xml:"id,attr"` - GUID string `json:"guid" xml:"guid,attr"` - Date string `json:"date" xml:"date"` - Start string `json:"start" xml:"start"` - Duration string `json:"duration" xml:"duration"` - Room string `json:"room" xml:"room"` - URL string `json:"url" xml:"url"` - Track string `json:"track" xml:"track"` - Type string `json:"type" xml:"type"` - Title string `json:"title" xml:"title"` - Abstract string `json:"abstract" xml:"abstract"` - Persons []Person `json:"persons" xml:"persons>person"` - Attachments []Attachment `json:"attachments" xml:"attachments>attachment"` - Links []Link `json:"links" xml:"links>link"` -} - -type Person struct { - ID int `json:"id" xml:"id,attr"` - Name string `json:"name" xml:",chardata"` -} - -type Attachment struct { - Type string `json:"string" xml:"id,attr"` - Href string `json:"href" xml:"href,attr"` - Name string `json:"name" xml:",chardata"` -} - -type Link struct { - Href string `json:"href" xml:"href,attr"` - Name string `json:"name" xml:",chardata"` + GetSchedule() (*Schedule, time.Time, error) + GetEventByID(id int32) *Event } type service struct { - schedule *Schedule pentabarfUrl string - lastUpdated time.Time - lock sync.Mutex + + 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, @@ -102,15 +39,25 @@ func NewService(pentabarfUrl string) (Service, error) { return service, nil } -func (s *service) GetSchedule() (*Schedule, *time.Time, error) { - if s.hasScheduleExpired() { - err := s.updateSchedule() - if err != nil { - return nil, nil, err - } +func (s *service) GetSchedule() (*Schedule, time.Time, error) { + err := s.updateSchedule() + if err != nil { + return nil, time.Time{}, err } - return s.schedule, &s.lastUpdated, nil + 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 { @@ -119,13 +66,16 @@ func (s *service) hasScheduleExpired() bool { } func (s *service) updateSchedule() error { - s.lock.Lock() - defer s.lock.Unlock() - 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 @@ -133,14 +83,34 @@ func (s *service) updateSchedule() error { reader := bufio.NewReader(res.Body) - var schedule Schedule + var schedule schedule decoder := xml.NewDecoder(reader) if err := decoder.Decode(&schedule); err != nil { return fmt.Errorf("failed to decode XML: %w", err) } - s.schedule = &schedule + 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 } |
