aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--api/dto/calendar.go15
-rw-r--r--api/handlers/calendar.go81
-rw-r--r--api/handlers/ical.go43
-rw-r--r--api/handlers/schedule.go2
-rw-r--r--api/router.go11
-rw-r--r--go.mod3
-rw-r--r--go.sum6
-rw-r--r--internal/config.go3
-rw-r--r--main.go8
-rw-r--r--pkg/calendar/service.go112
-rw-r--r--pkg/database/migrations/0001_initial.sql17
-rw-r--r--pkg/database/query/calendars.sql23
-rw-r--r--pkg/database/query/favourites.sql2
-rw-r--r--pkg/database/sqlc/calendars.sql.go97
-rw-r--r--pkg/database/sqlc/favourites.sql.go36
-rw-r--r--pkg/database/sqlc/models.go7
-rw-r--r--pkg/ical/service.go78
-rw-r--r--pkg/schedule/model.go72
-rw-r--r--pkg/schedule/parse.go205
-rw-r--r--pkg/schedule/service.go140
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
}
diff --git a/go.mod b/go.mod
index 2842eba..f6c40ee 100644
--- a/go.mod
+++ b/go.mod
@@ -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
diff --git a/go.sum b/go.sum
index 5708689..92c2e62 100644
--- a/go.sum
+++ b/go.sum
@@ -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 {
diff --git a/main.go b/main.go
index ae0ef9d..d5706b0 100644
--- a/main.go
+++ b/main.go
@@ -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
}