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
+1
View File
@@ -24,6 +24,7 @@ type Event struct {
Time string
Location string
AdminToken string
Description string
CreatedAt time.Time
}
+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 = ?;
+24 -5
View File
@@ -57,9 +57,9 @@ 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 {
@@ -69,6 +69,7 @@ type CreateEventParams struct {
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=
+33 -1
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)
@@ -125,6 +128,7 @@ type EventPageData struct {
TotalGoing int64
IsAdmin bool
BaseURL string
DescriptionHTML template.HTML
}
func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*EventPageData, error) {
@@ -176,6 +180,11 @@ 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,
@@ -183,6 +192,7 @@ func (s *Server) loadEventPage(r *http.Request, slug string, isAdmin bool) (*Eve
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 {