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 {
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 {
+5 -2
View File
@@ -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 = ?;
+30 -11
View File
@@ -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 = ?
`
+1
View File
@@ -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
)
+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/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=
+45 -13
View File
@@ -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")
+2
View File
@@ -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)
+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"
location TEXT NOT NULL,
admin_token TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
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.Location}}<span>&#128205; {{.Event.Location}}</span>{{end}}
</div>
{{if .DescriptionHTML}}
<div class="event-description">{{.DescriptionHTML}}</div>
{{end}}
</div>
<div id="slots-container"
@@ -87,6 +90,17 @@
{{end}}
{{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="claim-form-wrapper">
<form hx-post="/e/{{.Event.Slug}}/admin/{{.Event.AdminToken}}/slot"
+4
View File
@@ -29,6 +29,10 @@
<label>Location</label>
<input type="text" name="location" placeholder="e.g. Long Meadow, entrance at 9th St">
</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>
+36 -1
View File
@@ -97,6 +97,41 @@
font-size: 0.78rem;
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 {
font-family: 'DM Mono', monospace;
font-size: 0.7rem;
@@ -230,7 +265,7 @@
transition: box-shadow 0.1s;
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);
}
.btn-submit {