Add markdown event descriptions for hosts to provide context

Hosts can now add a free-form description (with markdown rendering via
goldmark) when creating or editing events. Descriptions render safely
with no raw HTML passthrough. Includes ALTER TABLE migration for
existing databases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 08:02:24 -04:00
parent 4fc79d4491
commit 7b0efb3c45
13 changed files with 189 additions and 35 deletions
+9 -8
View File
@@ -17,14 +17,15 @@ type Claim struct {
} }
type Event struct { type Event struct {
ID int64 ID int64
Slug string Slug string
Title string Title string
Date string Date string
Time string Time string
Location string Location string
AdminToken string AdminToken string
CreatedAt time.Time Description string
CreatedAt time.Time
} }
type Rsvp struct { type Rsvp struct {
+5 -2
View File
@@ -5,10 +5,13 @@ SELECT * FROM events WHERE slug = ?;
SELECT * FROM events WHERE admin_token = ?; SELECT * FROM events WHERE admin_token = ?;
-- name: CreateEvent :one -- name: CreateEvent :one
INSERT INTO events (slug, title, date, time, location, admin_token) INSERT INTO events (slug, title, date, time, location, admin_token, description)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING *; RETURNING *;
-- name: UpdateEventDescription :exec
UPDATE events SET description = ? WHERE id = ?;
-- name: UpdateEvent :exec -- name: UpdateEvent :exec
UPDATE events SET title = ?, date = ?, time = ?, location = ? WHERE id = ?; UPDATE events SET title = ?, date = ?, time = ?, location = ? WHERE id = ?;
+30 -11
View File
@@ -57,18 +57,19 @@ func (q *Queries) CreateClaim(ctx context.Context, arg CreateClaimParams) (Claim
} }
const createEvent = `-- name: CreateEvent :one const createEvent = `-- name: CreateEvent :one
INSERT INTO events (slug, title, date, time, location, admin_token) INSERT INTO events (slug, title, date, time, location, admin_token, description)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id, slug, title, date, time, location, admin_token, created_at RETURNING id, slug, title, date, time, location, admin_token, description, created_at
` `
type CreateEventParams struct { type CreateEventParams struct {
Slug string Slug string
Title string Title string
Date string Date string
Time string Time string
Location string Location string
AdminToken string AdminToken string
Description string
} }
func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event, error) { 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.Time,
arg.Location, arg.Location,
arg.AdminToken, arg.AdminToken,
arg.Description,
) )
var i Event var i Event
err := row.Scan( err := row.Scan(
@@ -89,6 +91,7 @@ func (q *Queries) CreateEvent(ctx context.Context, arg CreateEventParams) (Event
&i.Time, &i.Time,
&i.Location, &i.Location,
&i.AdminToken, &i.AdminToken,
&i.Description,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err
@@ -190,7 +193,7 @@ func (q *Queries) DeleteSlot(ctx context.Context, id int64) error {
} }
const getEventByAdminToken = `-- name: GetEventByAdminToken :one 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) { 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.Time,
&i.Location, &i.Location,
&i.AdminToken, &i.AdminToken,
&i.Description,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err
} }
const getEventBySlug = `-- name: GetEventBySlug :one 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) { 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.Time,
&i.Location, &i.Location,
&i.AdminToken, &i.AdminToken,
&i.Description,
&i.CreatedAt, &i.CreatedAt,
) )
return i, err return i, err
@@ -406,6 +411,20 @@ func (q *Queries) UpdateEvent(ctx context.Context, arg UpdateEventParams) error
return err 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 const updateSlot = `-- name: UpdateSlot :exec
UPDATE slots SET name = ?, emoji = ?, max_claims = ? WHERE id = ? UPDATE slots SET name = ?, emoji = ?, max_claims = ? WHERE id = ?
` `
+1
View File
@@ -5,4 +5,5 @@ go 1.22
require ( require (
github.com/go-chi/chi/v5 v5.2.5 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/mattn/go-sqlite3 v1.14.44 // indirect github.com/mattn/go-sqlite3 v1.14.44 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
) )
+2
View File
@@ -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/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 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ= 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=
+45 -13
View File
@@ -6,6 +6,7 @@ import (
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"html/template"
"log" "log"
"net/http" "net/http"
"strconv" "strconv"
@@ -17,6 +18,7 @@ import (
const ( const (
maxFieldLen = 200 maxFieldLen = 200
maxDescLen = 5000
maxNoteLen = 500 maxNoteLen = 500
maxSlots = 20 maxSlots = 20
maxRsvps = 200 maxRsvps = 200
@@ -57,6 +59,7 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) {
date := sanitize(r.FormValue("date"), maxFieldLen) date := sanitize(r.FormValue("date"), maxFieldLen)
time_ := sanitize(r.FormValue("time"), maxFieldLen) time_ := sanitize(r.FormValue("time"), maxFieldLen)
location := sanitize(r.FormValue("location"), maxFieldLen) location := sanitize(r.FormValue("location"), maxFieldLen)
description := sanitize(r.FormValue("description"), maxDescLen)
if title == "" { if title == "" {
http.Error(w, "Title is required", http.StatusBadRequest) http.Error(w, "Title is required", http.StatusBadRequest)
@@ -66,7 +69,7 @@ func (s *Server) handleCreateEvent(w http.ResponseWriter, r *http.Request) {
slug := randomSlug() slug := randomSlug()
token := randomToken() token := randomToken()
event, err := s.q.CreateEvent(r.Context(), db.CreateEventParams{ 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 { if err != nil {
log.Printf("create event: %v", err) log.Printf("create event: %v", err)
@@ -119,12 +122,13 @@ type SlotView struct {
} }
type EventPageData struct { type EventPageData struct {
Event db.Event Event db.Event
Slots []SlotView Slots []SlotView
Rsvps []db.Rsvp Rsvps []db.Rsvp
TotalGoing int64 TotalGoing int64
IsAdmin bool IsAdmin bool
BaseURL string BaseURL string
DescriptionHTML template.HTML
} }
func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) { 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)) totalGoing += int64(len(rsvps))
var descHTML template.HTML
if event.Description != "" {
descHTML = RenderMarkdown(event.Description)
}
return &EventPageData{ return &EventPageData{
Event: event, Event: event,
Slots: slotViews, Slots: slotViews,
Rsvps: rsvps, Rsvps: rsvps,
TotalGoing: totalGoing, TotalGoing: totalGoing,
IsAdmin: isAdmin, IsAdmin: isAdmin,
BaseURL: s.baseURL, BaseURL: s.baseURL,
DescriptionHTML: descHTML,
}, nil }, nil
} }
@@ -425,6 +435,28 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
pageTmpl["event"].ExecuteTemplate(w, "layout", data) 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) { func (s *Server) handleCreateSlot(w http.ResponseWriter, r *http.Request) {
slug := chi.URLParam(r, "slug") slug := chi.URLParam(r, "slug")
token := chi.URLParam(r, "token") token := chi.URLParam(r, "token")
+2
View File
@@ -104,6 +104,7 @@ func main() {
if _, err := database.Exec(schemaSQL); err != nil { if _, err := database.Exec(schemaSQL); err != nil {
log.Fatal("schema init: ", err) log.Fatal("schema init: ", err)
} }
runMigrations(database)
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"pct": func(count, max int64) int64 { "pct": func(count, max int64) int64 {
@@ -160,6 +161,7 @@ func main() {
// Admin // Admin
r.Get("/e/{slug}/admin/{token}", srv.handleAdmin) 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.Post("/e/{slug}/admin/{token}/slot", srv.handleCreateSlot)
r.Delete("/e/{slug}/admin/{token}/slot/{slotID}", srv.handleDeleteSlot) r.Delete("/e/{slug}/admin/{token}/slot/{slotID}", srv.handleDeleteSlot)
+23
View File
@@ -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())
}
+17
View File
@@ -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)
}
}
+1
View File
@@ -6,6 +6,7 @@ CREATE TABLE IF NOT EXISTS events (
time TEXT NOT NULL, -- e.g. "2:00 PM" time TEXT NOT NULL, -- e.g. "2:00 PM"
location TEXT NOT NULL, location TEXT NOT NULL,
admin_token TEXT NOT NULL UNIQUE, admin_token TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
+14
View File
@@ -26,6 +26,9 @@
{{if .Event.Time}}<span>&#128338; {{.Event.Time}}</span>{{end}} {{if .Event.Time}}<span>&#128338; {{.Event.Time}}</span>{{end}}
{{if .Event.Location}}<span>&#128205; {{.Event.Location}}</span>{{end}} {{if .Event.Location}}<span>&#128205; {{.Event.Location}}</span>{{end}}
</div> </div>
{{if .DescriptionHTML}}
<div class="event-description">{{.DescriptionHTML}}</div>
{{end}}
</div> </div>
<div id="slots-container" <div id="slots-container"
@@ -87,6 +90,17 @@
{{end}} {{end}}
{{if .IsAdmin}} {{if .IsAdmin}}
<div class="section-label" style="margin-top:40px">Admin: Description</div>
<div class="claim-form-wrapper">
<form method="POST" action="/e/{{.Event.Slug}}/admin/{{.Event.AdminToken}}/description">
<div class="form-row">
<label>Event description (markdown supported)</label>
<textarea name="description" rows="5" style="border:var(--border-w) solid var(--border);background:var(--cream);padding:10px 14px;font-family:'Bricolage Grotesque',sans-serif;font-size:0.95rem;resize:vertical;outline:none;">{{.Event.Description}}</textarea>
</div>
<button class="btn-submit" type="submit">Save description</button>
</form>
</div>
<div class="section-label" style="margin-top:40px">Admin: Add slot</div> <div class="section-label" style="margin-top:40px">Admin: Add slot</div>
<div class="claim-form-wrapper"> <div class="claim-form-wrapper">
<form hx-post="/e/{{.Event.Slug}}/admin/{{.Event.AdminToken}}/slot" <form hx-post="/e/{{.Event.Slug}}/admin/{{.Event.AdminToken}}/slot"
+4
View File
@@ -29,6 +29,10 @@
<label>Location</label> <label>Location</label>
<input type="text" name="location" placeholder="e.g. Long Meadow, entrance at 9th St"> <input type="text" name="location" placeholder="e.g. Long Meadow, entrance at 9th St">
</div> </div>
<div class="form-row">
<label>Description (optional, markdown supported)</label>
<textarea name="description" rows="4" placeholder="e.g. Bring your own blanket! We'll have a grill set up near the big oak tree." style="border:var(--border-w) solid var(--border);background:var(--cream);padding:10px 14px;font-family:'Bricolage Grotesque',sans-serif;font-size:0.95rem;resize:vertical;outline:none;"></textarea>
</div>
<div class="section-label" style="margin:24px 0 16px">Slots</div> <div class="section-label" style="margin:24px 0 16px">Slots</div>
+36 -1
View File
@@ -97,6 +97,41 @@
font-size: 0.78rem; font-size: 0.78rem;
color: #555; color: #555;
} }
.event-description {
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid #ddd;
font-size: 0.92rem;
line-height: 1.6;
}
.event-description h1 { font-size: 1.3rem; margin: 16px 0 8px; }
.event-description h2 { font-size: 1.15rem; margin: 14px 0 6px; }
.event-description h3 { font-size: 1.05rem; margin: 12px 0 4px; }
.event-description p { margin: 8px 0; }
.event-description ul, .event-description ol { margin: 8px 0; padding-left: 24px; }
.event-description li { margin: 4px 0; }
.event-description a { color: var(--ink); text-decoration: underline; }
.event-description code {
font-family: 'DM Mono', monospace;
background: var(--cream);
padding: 2px 6px;
font-size: 0.85em;
border: 1px solid #ddd;
}
.event-description pre {
background: var(--cream);
border: 1px solid #ddd;
padding: 12px;
overflow-x: auto;
margin: 8px 0;
}
.event-description pre code { background: none; border: none; padding: 0; }
.event-description blockquote {
border-left: 3px solid var(--ink);
padding-left: 12px;
margin: 8px 0;
color: #555;
}
.section-label { .section-label {
font-family: 'DM Mono', monospace; font-family: 'DM Mono', monospace;
font-size: 0.7rem; font-size: 0.7rem;
@@ -230,7 +265,7 @@
transition: box-shadow 0.1s; transition: box-shadow 0.1s;
appearance: none; appearance: none;
} }
.form-row input:focus, .form-row select:focus { .form-row input:focus, .form-row select:focus, .form-row textarea:focus {
box-shadow: 3px 3px 0 var(--ink); box-shadow: 3px 3px 0 var(--ink);
} }
.btn-submit { .btn-submit {