diff --git a/db/models.go b/db/models.go index a5f25c0..84ea7aa 100644 --- a/db/models.go +++ b/db/models.go @@ -17,14 +17,15 @@ type Claim struct { } type Event struct { - ID int64 - Slug string - Title string - Date string - Time string - Location string - AdminToken string - CreatedAt time.Time + ID int64 + Slug string + Title string + Date string + Time string + Location string + AdminToken string + Description string + CreatedAt time.Time } type Rsvp struct { diff --git a/db/queries.sql b/db/queries.sql index b2ab4e8..e7e8a96 100644 --- a/db/queries.sql +++ b/db/queries.sql @@ -5,10 +5,13 @@ SELECT * FROM events WHERE slug = ?; SELECT * FROM events WHERE admin_token = ?; -- name: CreateEvent :one -INSERT INTO events (slug, title, date, time, location, admin_token) -VALUES (?, ?, ?, ?, ?, ?) +INSERT INTO events (slug, title, date, time, location, admin_token, description) +VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *; +-- name: UpdateEventDescription :exec +UPDATE events SET description = ? WHERE id = ?; + -- name: UpdateEvent :exec UPDATE events SET title = ?, date = ?, time = ?, location = ? WHERE id = ?; diff --git a/db/queries.sql.go b/db/queries.sql.go index 6232f17..512062b 100644 --- a/db/queries.sql.go +++ b/db/queries.sql.go @@ -57,18 +57,19 @@ func (q *Queries) CreateClaim(ctx context.Context, arg CreateClaimParams) (Claim } const createEvent = `-- name: CreateEvent :one -INSERT INTO events (slug, title, date, time, location, admin_token) -VALUES (?, ?, ?, ?, ?, ?) -RETURNING id, slug, title, date, time, location, admin_token, created_at +INSERT INTO events (slug, title, date, time, location, admin_token, description) +VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING id, slug, title, date, time, location, admin_token, description, created_at ` type CreateEventParams struct { - Slug string - Title string - Date string - Time string - Location string - AdminToken string + Slug string + Title string + Date string + Time string + Location string + AdminToken string + Description string } func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) { @@ -79,6 +80,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event arg.Time, arg.Location, arg.AdminToken, + arg.Description, ) var i Event err := row.Scan( @@ -89,6 +91,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event &i.Time, &i.Location, &i.AdminToken, + &i.Description, &i.CreatedAt, ) return i, err @@ -190,7 +193,7 @@ func (q *Queries) DeleteSlot(ctx context.Context, id int64) error { } const getEventByAdminToken = `-- name: GetEventByAdminToken :one -SELECT id, slug, title, date, time, location, admin_token, created_at FROM events WHERE admin_token = ? +SELECT id, slug, title, date, time, location, admin_token, description, created_at FROM events WHERE admin_token = ? ` func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) (Event, error) { @@ -204,13 +207,14 @@ func (q *Queries) GetEventByAdminToken(ctx context.Context, adminToken string) ( &i.Time, &i.Location, &i.AdminToken, + &i.Description, &i.CreatedAt, ) return i, err } const getEventBySlug = `-- name: GetEventBySlug :one -SELECT id, slug, title, date, time, location, admin_token, created_at FROM events WHERE slug = ? +SELECT id, slug, title, date, time, location, admin_token, description, created_at FROM events WHERE slug = ? ` func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error) { @@ -224,6 +228,7 @@ func (q *Queries) GetEventBySlug(ctx context.Context, slug string) (Event, error &i.Time, &i.Location, &i.AdminToken, + &i.Description, &i.CreatedAt, ) return i, err @@ -406,6 +411,20 @@ func (q *Queries) UpdateEvent(ctx context.Context, arg UpdateEventParams) error return err } +const updateEventDescription = `-- name: UpdateEventDescription :exec +UPDATE events SET description = ? WHERE id = ? +` + +type UpdateEventDescriptionParams struct { + Description string + ID int64 +} + +func (q *Queries) UpdateEventDescription(ctx context.Context, arg UpdateEventDescriptionParams) error { + _, err := q.db.ExecContext(ctx, updateEventDescription, arg.Description, arg.ID) + return err +} + const updateSlot = `-- name: UpdateSlot :exec UPDATE slots SET name = ?, emoji = ?, max_claims = ? WHERE id = ? ` diff --git a/go.mod b/go.mod index 6f2ce4a..6fe8f14 100644 --- a/go.mod +++ b/go.mod @@ -5,4 +5,5 @@ go 1.22 require ( github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/mattn/go-sqlite3 v1.14.44 // indirect + github.com/yuin/goldmark v1.8.2 // indirect ) diff --git a/go.sum b/go.sum index b61ec51..faeb928 100644 --- a/go.sum +++ b/go.sum @@ -2,3 +2,5 @@ github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8= github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= +github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= +github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= diff --git a/handlers.go b/handlers.go index 9b1094d..62705e4 100644 --- a/handlers.go +++ b/handlers.go @@ -6,6 +6,7 @@ import ( "database/sql" "encoding/hex" "fmt" + "html/template" "log" "net/http" "strconv" @@ -17,6 +18,7 @@ import ( const ( maxFieldLen = 200 + maxDescLen = 5000 maxNoteLen = 500 maxSlots = 20 maxRsvps = 200 @@ -57,6 +59,7 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) { date := sanitize(r.FormValue("date"), maxFieldLen) time_ := sanitize(r.FormValue("time"), maxFieldLen) location := sanitize(r.FormValue("location"), maxFieldLen) + description := sanitize(r.FormValue("description"), maxDescLen) if title == "" { http.Error(w, "Title is required", http.StatusBadRequest) @@ -66,7 +69,7 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) { slug := randomSlug() token := randomToken() event, err := s.q.CreateEvent(r.Context(), db.CreateEventParams{ - Slug: slug, Title: title, Date: date, Time: time_, Location: location, AdminToken: token, + Slug: slug, Title: title, Date: date, Time: time_, Location: location, AdminToken: token, Description: description, }) if err != nil { log.Printf("create event: %v", err) @@ -119,12 +122,13 @@ type SlotView struct { } type EventPageData struct { - Event db.Event - Slots []SlotView - Rsvps []db.Rsvp - TotalGoing int64 - IsAdmin bool - BaseURL string + Event db.Event + Slots []SlotView + Rsvps []db.Rsvp + TotalGoing int64 + IsAdmin bool + BaseURL string + DescriptionHTML template.HTML } func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) { @@ -176,13 +180,19 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve } totalGoing += int64(len(rsvps)) + var descHTML template.HTML + if event.Description != "" { + descHTML = RenderMarkdown(event.Description) + } + return &EventPageData{ - Event: event, - Slots: slotViews, - Rsvps: rsvps, - TotalGoing: totalGoing, - IsAdmin: isAdmin, - BaseURL: s.baseURL, + Event: event, + Slots: slotViews, + Rsvps: rsvps, + TotalGoing: totalGoing, + IsAdmin: isAdmin, + BaseURL: s.baseURL, + DescriptionHTML: descHTML, }, nil } @@ -425,6 +435,28 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) { pageTmpl["event"].ExecuteTemplate(w, "layout", data) } +func (s *Server) handleUpdateDescription(w http.ResponseWriter, r *http.Request) { + slug := chi.URLParam(r, "slug") + token := chi.URLParam(r, "token") + + event, err := s.q.GetEventBySlug(r.Context(), slug) + if err != nil || event.AdminToken != token { + http.NotFound(w, r) + return + } + + r.Body = http.MaxBytesReader(w, r.Body, 32*1024) + r.ParseForm() + description := sanitize(r.FormValue("description"), maxDescLen) + + s.q.UpdateEventDescription(r.Context(), db.UpdateEventDescriptionParams{ + Description: description, + ID: event.ID, + }) + + http.Redirect(w, r, fmt.Sprintf("/e/%s/admin/%s", slug, token), http.StatusSeeOther) +} + func (s *Server) handleCreateSlot(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") token := chi.URLParam(r, "token") diff --git a/main.go b/main.go index 6a5b83c..48ccdce 100644 --- a/main.go +++ b/main.go @@ -104,6 +104,7 @@ func main() { if _, err := database.Exec(schemaSQL); err != nil { log.Fatal("schema init: ", err) } + runMigrations(database) funcMap := template.FuncMap{ "pct": func(count, max int64) int64 { @@ -160,6 +161,7 @@ func main() { // Admin r.Get("/e/{slug}/admin/{token}", srv.handleAdmin) + r.Post("/e/{slug}/admin/{token}/description", srv.handleUpdateDescription) r.Post("/e/{slug}/admin/{token}/slot", srv.handleCreateSlot) r.Delete("/e/{slug}/admin/{token}/slot/{slotID}", srv.handleDeleteSlot) diff --git a/markdown.go b/markdown.go new file mode 100644 index 0000000..86b4ae9 --- /dev/null +++ b/markdown.go @@ -0,0 +1,23 @@ +package main + +import ( + "bytes" + "html/template" + + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/renderer/html" +) + +var mdRenderer = goldmark.New( + goldmark.WithRendererOptions( + html.WithHardWraps(), + ), +) + +func RenderMarkdown(input string) template.HTML { + var buf bytes.Buffer + if err := mdRenderer.Convert([]byte(input), &buf); err != nil { + return template.HTML(template.HTMLEscapeString(input)) + } + return template.HTML(buf.String()) +} diff --git a/migrate.go b/migrate.go new file mode 100644 index 0000000..d716cdc --- /dev/null +++ b/migrate.go @@ -0,0 +1,17 @@ +package main + +import ( + "database/sql" + "log" + "strings" +) + +func runMigrations(database *sql.DB) { + _, err := database.Exec(`ALTER TABLE events ADD COLUMN description TEXT DEFAULT ''`) + if err != nil { + if strings.Contains(err.Error(), "duplicate column name") { + return + } + log.Printf("migration warning: %v", err) + } +} diff --git a/schema.sql b/schema.sql index 440fa0f..27d7d79 100644 --- a/schema.sql +++ b/schema.sql @@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS events ( time TEXT NOT NULL, -- e.g. "2:00 PM" location TEXT NOT NULL, admin_token TEXT NOT NULL UNIQUE, + description TEXT NOT NULL DEFAULT '', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); diff --git a/templates/event.html b/templates/event.html index 1d0e1d0..cf034d0 100644 --- a/templates/event.html +++ b/templates/event.html @@ -26,6 +26,9 @@ {{if .Event.Time}}🕒 {{.Event.Time}}{{end}} {{if .Event.Location}}📍 {{.Event.Location}}{{end}} + {{if .DescriptionHTML}} +