henwen/internal/front/server.go

504 lines
13 KiB
Go

package front
import (
"context"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strconv"
"github.com/rickb777/date"
hm "gitlab.codemonkeysoftware.net/b/hatmill"
a "gitlab.codemonkeysoftware.net/b/hatmill/attribute"
e "gitlab.codemonkeysoftware.net/b/hatmill/element"
"gitlab.codemonkeysoftware.net/b/henwen/internal/back"
)
const (
pathRoot = "/"
pathCreate = "/create"
pathDoCreate = "/create/do"
pathCreateSuccess = "/create/success"
pathAdmin = "/admin"
pathVote = "/vote"
pathDoVote = "/vote/do"
pathVoteSuccess = "/vote/success"
)
type handler struct {
mux *http.ServeMux
store *back.Store
title string
baseURL string
}
type HandlerParams struct {
Title string
Store *back.Store
BaseURL string
}
func NewHandler(params HandlerParams) http.Handler {
h := &handler{
store: params.Store,
title: params.Title,
baseURL: params.BaseURL,
mux: http.NewServeMux(),
}
h.mux.HandleFunc(pathRoot, h.handleRoot)
h.mux.HandleFunc(pathCreate, h.handleCreate)
h.mux.HandleFunc(pathDoCreate, h.handleDoCreate)
h.mux.HandleFunc(pathAdmin, h.handleAdmin)
h.mux.HandleFunc(pathVote, h.handleVote)
h.mux.HandleFunc(pathDoVote, h.handleDoVote)
h.mux.HandleFunc(pathVoteSuccess, h.handleVoteSuccess)
h.mux.HandleFunc(pathCreateSuccess, h.handleCreateSuccess)
return h
}
func (h *handler) handleRoot(w http.ResponseWriter, r *http.Request) {
body := hm.Terms{
e.A(a.Href(pathCreate))(
hm.Text("Create event"),
),
}
_ = h.writePage(w, "Welcome!", body)
}
var timeLabels = []string{
"12 AM", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11",
"12 PM", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11",
}
type voteState struct {
eventAlphaID string
name string
earliest, latest date.Date
}
func voteForm(disabled bool, st voteState) hm.Term {
dateSpan := st.latest.Sub(st.earliest)
var dates []date.Date
for offset := date.PeriodOfDays(0); offset <= dateSpan; offset++ {
dates = append(dates, st.earliest.Add(offset))
}
var ths = hm.Terms{e.Th()()}
for _, date := range dates {
ths = append(ths, e.Th()(hm.Text(date.Format("Jan 2"))))
}
var rows = hm.Terms{e.Thead()(ths)}
for hour := 0; hour < 24; hour++ {
var row = hm.Terms{
e.Td()(hm.Text(timeLabels[hour])),
}
for _, day := range dates {
value := fmt.Sprintf("%8s-%02d", day.Format("20060102"), hour)
row = append(row, e.Td()(e.Input(
a.Name(fieldNameDateHour),
a.Value(value),
a.Type("checkbox"),
a.Disabled(disabled),
)))
}
rows = append(rows, e.Tr()(row))
}
return e.Form(a.Method(http.MethodPost), a.Action(pathDoVote))(
e.Input(a.Type("hidden"), a.Name(fieldNameEventID), a.Value(st.eventAlphaID)),
e.Label(a.For(fieldNameGuestName))(hm.Text("What's your name?")),
e.Br(),
e.Input(a.Name(fieldNameGuestName), a.Size(40), a.Value(st.name), a.Disabled(disabled)),
e.Fieldset()(
e.Legend()(hm.Text("When are you available?")),
e.Table()(rows),
),
e.Input(a.Type("submit"), a.Value("Submit")),
)
}
func notFound(w http.ResponseWriter, err error, msg string) bool {
if err == back.ErrNotFound {
http.Error(w, msg, http.StatusNotFound)
return true
}
return false
}
func invalidForm(w http.ResponseWriter, err error) bool {
if err != nil {
http.Error(w,
"Invalid form values. That shouldn't have been possible. Please try again later.",
http.StatusBadRequest)
logError(err)
return true
}
return false
}
func logError(err error) {
log.Println("ERROR:", err.Error())
}
func internalServerError(w http.ResponseWriter, err error) bool {
if err != nil {
http.Error(w, "Something went wrong. Please try again later.", http.StatusInternalServerError)
logError(err)
return true
}
return false
}
func (h *handler) handleVote(w http.ResponseWriter, r *http.Request) {
eventAlphaID := r.URL.Query().Get(fieldNameEventID)
event, err := h.store.GetEventMetadata(context.Background(), back.GetEventMetadataQuery{
EventID: eventAlphaID,
})
if notFound(w, err, "Event not found") || internalServerError(w, err) {
return
}
state := voteState{
eventAlphaID: eventAlphaID,
earliest: event.Earliest,
latest: event.Latest,
}
body := hm.Terms{
e.P()(hm.Text(event.Description)),
voteForm(false, state),
}
_ = h.writePage(w, event.Name, body)
}
func (h *handler) handleDoVote(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if invalidForm(w, err) {
return
}
fmt.Printf("received form: %+v\n", r.Form)
cmd := back.CreateEventResponseCommand{
EventID: r.Form.Get(fieldNameEventID),
GuestName: r.Form.Get(fieldNameGuestName),
DateHours: make(map[date.Date]map[int]struct{}),
}
for _, dateHourString := range r.Form[fieldNameDateHour] {
var dateString string
var hour int
_, err := fmt.Sscanf(dateHourString, "%8s-%02d", &dateString, &hour)
if invalidForm(w, err) {
return
}
d, err := date.Parse("20060102", dateString)
if invalidForm(w, err) {
return
}
if cmd.DateHours[d] == nil {
cmd.DateHours[d] = make(map[int]struct{})
}
cmd.DateHours[d][hour] = struct{}{}
}
eventResponse, err := h.store.CreateEventResponse(r.Context(), cmd)
if notFound(w, err, "Event not found") || internalServerError(w, err) {
return
}
var successQuery = make(url.Values)
successQuery.Add(fieldNameEventID, cmd.EventID)
successQuery.Add(fieldNameResponseID, eventResponse.ResponseID)
http.Redirect(w, r, pathVoteSuccess+"?"+successQuery.Encode(), http.StatusSeeOther)
}
func (h *handler) handleVoteSuccess(w http.ResponseWriter, r *http.Request) {
eventResponse, err := h.store.GetEventResponse(r.Context(), back.GetEventResponseQuery{
EventID: r.URL.Query().Get(fieldNameEventID),
ResponseID: r.URL.Query().Get(fieldNameResponseID),
})
if notFound(w, err, "Event response not found.") || internalServerError(w, err) {
return
}
event, err := h.store.GetEventMetadata(r.Context(), back.GetEventMetadataQuery{
EventID: r.URL.Query().Get(fieldNameEventID),
})
if notFound(w, err, "Event not found") || internalServerError(w, err) {
return
}
state := voteState{
name: eventResponse.GuestName,
earliest: event.Earliest,
latest: event.Latest,
}
body := hm.Terms{
e.P()(hm.Text(event.Description)),
e.H3()(hm.Text("Thanks for voting!")),
e.P()(
hm.Text("You can edit your response anytime at "),
e.A(a.Href("#"))(hm.Text("this link")),
hm.Text("."),
),
voteForm(true, state),
}
_ = h.writePage(w, event.Name, body)
}
func (h *handler) handleCreate(w http.ResponseWriter, r *http.Request) {
body := hm.Terms{
e.Form(a.Action(pathDoCreate), a.Method(http.MethodPost))(
e.Label(a.For(fieldNameEventName))(hm.Text("Event name")),
e.Input(a.Name(fieldNameEventName)),
e.Label(a.For(fieldNameDescription))(hm.Text("Description")),
e.Textarea(a.Name(fieldNameDescription), a.Placeholder("What's going on?"))(),
e.Label(a.For(fieldNameEarliest))(hm.Text("Earliest date")),
e.Input(a.Name(fieldNameEarliest), a.Type("date")),
e.Label(a.For(fieldNameLatest))(hm.Text("Latest date")),
e.Input(a.Name(fieldNameLatest), a.Type("date")),
e.Input(a.Type("submit")),
),
}
_ = h.writePage(w, "Create an event", body)
}
func (h *handler) handleDoCreate(w http.ResponseWriter, r *http.Request) {
earliest, err := date.Parse(formDateLayout, r.FormValue(fieldNameEarliest))
if invalidForm(w, err) {
return
}
latest, err := date.Parse(formDateLayout, r.FormValue(fieldNameLatest))
if invalidForm(w, err) {
return
}
eventName := r.FormValue(fieldNameEventName)
if eventName == "" {
fmt.Fprint(w, "Event name is required")
return
}
description := r.FormValue(fieldNameDescription)
event, err := h.store.CreateEvent(context.Background(), back.CreateEventCommand{
Name: eventName,
Description: description,
Earliest: earliest,
Latest: latest,
})
if err != nil {
fmt.Fprint(w, err)
return
}
var successQuery = make(url.Values)
successQuery.Add(fieldNameEventID, event.EventID)
successQuery.Add(fieldNameAdminCode, event.AdminCode)
http.Redirect(w, r, pathCreateSuccess+"?"+successQuery.Encode(), http.StatusSeeOther)
}
func (h *handler) blockUnauthorizedAdmin(w http.ResponseWriter, r *http.Request) bool {
eventID := r.URL.Query().Get(fieldNameEventID)
adminCode := r.URL.Query().Get(fieldNameAdminCode)
err := h.store.AuthorizeEventAdmin(context.Background(), back.CheckEventAdminCodeQuery{
EventID: eventID,
AdminCode: adminCode,
})
var authError back.UnauthorizedError
if errors.As(err, &authError) {
http.Error(w, "Event not found", http.StatusNotFound)
logError(authError)
return true
}
return internalServerError(w, err)
}
func (h *handler) handleCreateSuccess(w http.ResponseWriter, r *http.Request) {
if h.blockUnauthorizedAdmin(w, r) {
return
}
eventID := r.URL.Query().Get(fieldNameEventID)
event, err := h.store.GetEventMetadata(r.Context(), back.GetEventMetadataQuery{
EventID: eventID,
})
if notFound(w, err, "Event not found") || internalServerError(w, err) {
return
}
var adminQuery = make(url.Values)
adminQuery.Add(fieldNameEventID, eventID)
adminQuery.Add(fieldNameAdminCode, r.URL.Query().Get(fieldNameAdminCode))
adminURL := h.baseURL + pathAdmin + "?" + adminQuery.Encode()
var voteQuery = make(url.Values)
voteQuery.Add(fieldNameEventID, eventID)
voteURL := h.baseURL + pathVote + "?" + voteQuery.Encode()
const dateDisplayFmt = "Monday, January 2, 2006"
body := hm.Terms{
e.P()(
hm.Text("You can find it again at "),
e.A(a.Href(adminURL))(hm.Text(adminURL)),
hm.Text("."),
),
e.P()(
hm.Text("Your guests can vote on times at "),
e.A(a.Href(voteURL))(hm.Text(voteURL)),
hm.Text("."),
),
e.H3()(hm.Text("Name")),
hm.Text(event.Name),
e.H3()(hm.Text("Description")),
hm.Text(event.Description),
e.H3()(hm.Text("Earliest date")),
hm.Text(event.Earliest.Format(dateDisplayFmt)),
e.H3()(hm.Text("Latest date")),
hm.Text(event.Latest.Format(dateDisplayFmt)),
}
_ = h.writePage(w, "Created event", body)
}
func (h *handler) handleAdmin(w http.ResponseWriter, r *http.Request) {
if h.blockUnauthorizedAdmin(w, r) {
return
}
query := r.URL.Query()
eventID := query.Get(fieldNameEventID)
metadata, err := h.store.GetEventMetadata(context.Background(), back.GetEventMetadataQuery{
EventID: eventID,
})
if notFound(w, err, "Event not found") || internalServerError(w, err) {
return
}
summary, err := h.store.GetEventResponseSummary(context.Background(), back.GetEventResponseSummaryQuery{
EventID: eventID,
})
if err != nil {
fmt.Fprint(w, err)
return
}
// Build the respondents list
respondentsList := e.Ul()()
for _, r := range summary.RespondentNames {
respondentsList.Children = append(respondentsList.Children, e.Li()(hm.Text(r)))
}
// Build the counts table
dateSpan := metadata.Latest.Sub(metadata.Earliest)
var dates []date.Date
for offset := date.PeriodOfDays(0); offset <= dateSpan; offset++ {
dates = append(dates, metadata.Earliest.Add(offset))
}
var ths = hm.Terms{e.Th()()}
for _, date := range dates {
ths = append(ths, e.Th()(hm.Text(date.Format("Jan 2"))))
}
var rows = hm.Terms{e.Thead()(ths)}
for hour := 0; hour < 24; hour++ {
var row = hm.Terms{
e.Td()(hm.Text(timeLabels[hour])),
}
for _, day := range dates {
hourCounts := summary.PerHourCounts[day]
var count int
if hourCounts != nil {
count = hourCounts[hour]
}
row = append(row, e.Td()(hm.Text(strconv.Itoa(count))))
}
rows = append(rows, e.Tr()(row))
}
countsTable := e.Table()(rows)
body := hm.Terms{
e.Form()(
e.Label(a.For(fieldNameEventName))(hm.Text("Event name")),
e.Input(
a.Name(fieldNameEventName),
a.Value(metadata.Name),
a.Disabled(true),
),
e.Br(),
e.Label(a.For(fieldNameDescription))(hm.Text("Description")),
e.Textarea(
a.Name(fieldNameDescription),
a.Disabled(true),
)(hm.Text(metadata.Description)),
e.Br(),
e.Label(a.For(fieldNameEarliest))(hm.Text("Earliest date")),
e.Input(
a.Name(fieldNameEarliest),
a.Type("date"),
a.Value(metadata.Earliest.Format(formDateLayout)),
a.Disabled(true),
),
e.Br(),
e.Label(a.For(fieldNameLatest))(hm.Text("Latest date")),
e.Input(
a.Name(fieldNameLatest),
a.Type("date"),
a.Value(metadata.Latest.Format(formDateLayout)),
a.Disabled(true),
),
),
e.H3()(hm.Text("Respondents")),
respondentsList,
e.H3()(hm.Text("Results")),
countsTable,
}
_ = h.writePage(w, "Edit your event", body)
}
func (h *handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
h.mux.ServeHTTP(w, r)
}
func (h *handler) writePage(w io.Writer, title string, contents hm.Term) error {
page := e.Html()(
e.Head()(
e.Title()(hm.Text(h.title+" — "+title)),
),
e.Body()(
e.H1()(e.A(a.Href(h.baseURL+pathRoot))(hm.Text(h.title))),
e.Div()(
e.H2()(hm.Text(title)),
contents,
),
),
)
_, err := hm.WriteDocument(w, page)
return err
}
const (
fieldNameDateHour = "date_hour"
fieldNameEarliest = "earliest_date"
fieldNameLatest = "latest_date"
fieldNameEventName = "event_name"
fieldNameDescription = "event_description"
fieldNameGuestName = "guest_name"
fieldNameEventID = "event_id"
fieldNameAdminCode = "admin_code"
fieldNameResponseID = "response_id"
)
const formDateLayout = "2006-01-02"