Move front and back into internal

This commit is contained in:
2020-10-11 12:21:17 -06:00
parent 1d8694846f
commit 6c885d25c2
6 changed files with 4 additions and 5 deletions

View File

@ -0,0 +1,27 @@
package back
import (
"bytes"
"crypto/rand"
"math/big"
)
var chars = []byte("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func SecureGenString(length int) (string, error) {
charsLength := big.NewInt(int64(len(chars)))
var maxN big.Int
maxN.Exp(charsLength, big.NewInt(int64(length)), nil)
n, err := rand.Int(rand.Reader, &maxN)
if err != nil {
return "", err
}
var buf bytes.Buffer
for n.Cmp(&big.Int{}) == 1 {
var charIdx big.Int
n.DivMod(n, charsLength, &charIdx)
_ = buf.WriteByte(chars[charIdx.Int64()])
}
return buf.String(), nil
}

430
internal/back/store.go Normal file
View File

@ -0,0 +1,430 @@
package back
import (
"context"
"errors"
"fmt"
"log"
"os"
"crawshaw.io/sqlite"
"crawshaw.io/sqlite/sqlitex"
"github.com/rickb777/date"
)
var ErrNotFound = errors.New("not found")
type UnauthorizedError struct {
EventID string
AdminCode string
}
func (u UnauthorizedError) Error() string {
return fmt.Sprintf("unauthorized: EventID = %s, AdminCode = %s", u.EventID, u.AdminCode)
}
type GenString func(length int) (string, error)
type Store struct {
pool *sqlitex.Pool
genString GenString
}
func NewStore(filename string, genString GenString) (*Store, error) {
var needCreate bool
if _, err := os.Stat(filename); os.IsNotExist(err) {
needCreate = true
}
// If the file exists, then assume it was created properly.
pool, err := sqlitex.Open(filename, 0, 10)
if err != nil {
return nil, err
}
store := &Store{
pool: pool,
genString: genString,
}
if needCreate {
log.Println("creating schema")
err = store.createSchema()
if err != nil {
defer pool.Close()
return nil, err
}
}
return store, nil
}
func NewMemoryStore(genString GenString) (*Store, error) {
pool, err := sqlitex.Open("file::memory:?mode=memory&cache=shared", 0, 10)
if err != nil {
return nil, err
}
store := &Store{
pool: pool,
genString: genString,
}
err = store.createSchema()
if err != nil {
defer pool.Close()
return nil, err
}
return store, nil
}
func (s *Store) Close() error {
return s.pool.Close()
}
const schema = `
CREATE TABLE event (
id TEXT NOT NULL PRIMARY KEY,
admin_code TEXT NOT NULL,
name TEXT NOT NULL,
description TEXT NOT NULL,
earliest_date DATE NOT NULL,
latest_date DATE NOT NULL
);
CREATE TABLE response (
id TEXT NOT NULL,
event_id TEXT NOT NULL,
guest_name TEXT NOT NULL,
PRIMARY KEY (id, event_id),
FOREIGN KEY (event_id) REFERENCES event(id)
);
CREATE TABLE response_time (
event_id TEXT NOT NULL,
response_id TEXT NOT NULL,
date DATE NOT NULL,
time INTEGER NOT NULL,
CHECK (0 <= time < 24),
UNIQUE (response_id, date, time),
FOREIGN KEY(event_id, response_id) REFERENCES response(event_id, id)
);
CREATE TRIGGER enforce_valid_response_dates
BEFORE INSERT ON response_time
BEGIN
SELECT
CASE
WHEN NEW.date NOT BETWEEN event.earliest_date AND event.latest_date
THEN RAISE(ABORT, 'response date is out of range')
END
FROM response
JOIN event ON response.event_id = event.id
WHERE response.id = NEW.response_id;
END;`
func (s *Store) createSchema() error {
conn := s.pool.Get(context.Background())
defer s.pool.Put(conn)
return sqlitex.ExecScript(conn, schema)
}
type CreateEventCommand struct {
Name string
Description string
Earliest, Latest date.Date
}
type CreateEventResult struct {
EventID, AdminCode string
}
const dbDateLayout = "2006-01-02"
func (s *Store) CreateEvent(ctx context.Context, cmd CreateEventCommand) (result CreateEventResult, err error) {
const idLength = 10
const adminCodeLength = 10
conn := s.pool.Get(ctx)
defer s.pool.Put(conn)
id, err := s.genString(idLength)
if err != nil {
return
}
adminCode, err := s.genString(adminCodeLength)
if err != nil {
return
}
const query = `
INSERT INTO event(id, admin_code, name, description, earliest_date, latest_date)
VALUES (?, ?, ?, ?, ?, ?);`
err = sqlitex.Exec(conn, query, nil,
id,
adminCode,
cmd.Name,
cmd.Description,
cmd.Earliest.Format(dbDateLayout),
cmd.Latest.Format(dbDateLayout),
)
if err != nil {
return
}
result.AdminCode = adminCode
result.EventID = id
return
}
type CheckEventAdminCodeQuery struct {
EventID string
AdminCode string
}
func (s *Store) AuthorizeEventAdmin(ctx context.Context, query CheckEventAdminCodeQuery) error {
conn := s.pool.Get(ctx)
defer s.pool.Put(conn)
const dbQuery = `
SELECT 1
FROM event
WHERE id = ? AND admin_code = ?;`
var doesMatch bool
err := sqlitex.Exec(conn, dbQuery,
func(stmt *sqlite.Stmt) error {
doesMatch = true
return nil
}, query.EventID, query.AdminCode)
if err != nil {
return err
}
if !doesMatch {
return UnauthorizedError{
EventID: query.EventID,
AdminCode: query.AdminCode,
}
}
return nil
}
type GetEventMetadataQuery struct {
EventID string
}
type GetEventMetadataResult struct {
Name string
Description string
Earliest, Latest date.Date
}
func (s *Store) GetEventMetadata(ctx context.Context, query GetEventMetadataQuery) (GetEventMetadataResult, error) {
conn := s.pool.Get(ctx)
defer s.pool.Put(conn)
const dbQuery = `
SELECT name, description, earliest_date, latest_date
FROM event
WHERE id = ?;`
var result GetEventMetadataResult
var found bool
err := sqlitex.Exec(conn, dbQuery,
func(stmt *sqlite.Stmt) error {
found = true
result.Name = stmt.ColumnText(0)
result.Description = stmt.ColumnText(1)
earliestDateString := stmt.ColumnText(2)
latestDateString := stmt.ColumnText(3)
var err error
result.Earliest, err = date.Parse(dbDateLayout, earliestDateString)
if err != nil {
return err
}
result.Latest, err = date.Parse(dbDateLayout, latestDateString)
return err
}, query.EventID)
if err != nil {
return GetEventMetadataResult{}, err
}
if !found {
return GetEventMetadataResult{}, ErrNotFound
}
return result, nil
}
type GetEventResponseSummaryQuery struct {
EventID string
}
type GetEventResponseSummaryResult struct {
RespondentNames []string
PerHourCounts map[date.Date]map[int]int
}
func (s *Store) GetEventResponseSummary(ctx context.Context, query GetEventResponseSummaryQuery) (GetEventResponseSummaryResult, error) {
conn := s.pool.Get(ctx)
defer s.pool.Put(conn)
var found bool
const eventIDQuery = `
SELECT 1
FROM event
WHERE id = ?`
var err = sqlitex.Exec(conn, eventIDQuery,
func(stmt *sqlite.Stmt) error {
found = true
return nil
}, query.EventID)
if err != nil {
return GetEventResponseSummaryResult{}, err
}
if !found {
return GetEventResponseSummaryResult{}, ErrNotFound
}
var result GetEventResponseSummaryResult
const respondentNameQuery = `
SELECT guest_name
FROM response
WHERE event_id = ?
ORDER BY guest_name ASC;`
err = sqlitex.Exec(conn, respondentNameQuery,
func(stmt *sqlite.Stmt) error {
result.RespondentNames = append(result.RespondentNames, stmt.ColumnText(0))
return nil
}, query.EventID)
if err != nil {
return GetEventResponseSummaryResult{}, err
}
result.PerHourCounts = make(map[date.Date]map[int]int)
const perHourCountQuery = `
SELECT date, time, COUNT(*)
FROM response_time
WHERE event_id = ?
GROUP BY date, time;`
err = sqlitex.Exec(conn, perHourCountQuery,
func(stmt *sqlite.Stmt) error {
d, err := date.Parse(dbDateLayout, stmt.ColumnText(0))
if err != nil {
return err
}
if result.PerHourCounts[d] == nil {
result.PerHourCounts[d] = make(map[int]int)
}
result.PerHourCounts[d][stmt.ColumnInt(1)] = stmt.ColumnInt(2)
return nil
}, query.EventID)
if err != nil {
return GetEventResponseSummaryResult{}, err
}
return result, nil
}
type CreateEventResponseCommand struct {
EventID string
GuestName string
DateHours map[date.Date]map[int]struct{}
}
type CreateEventResponseResult struct {
ResponseID string
}
func (s *Store) CreateEventResponse(ctx context.Context, cmd CreateEventResponseCommand) (result CreateEventResponseResult, err error) {
const responseIDLength = 10
conn := s.pool.Get(ctx)
defer s.pool.Put(conn)
defer sqlitex.Save(conn)(&err)
responseID, err := s.genString(responseIDLength)
if err != nil {
return
}
const responseQuery = `
INSERT INTO response(event_id, id, guest_name)
VALUES (?, ?, ?);`
err = sqlitex.Exec(conn, responseQuery, nil, cmd.EventID, responseID, cmd.GuestName)
if err != nil {
return
}
const responseTimeQuery = `
INSERT INTO response_time(event_id, response_id, date, time)
VALUES (?, ?, ?, ?);`
for d, hs := range cmd.DateHours {
for h := range hs {
err = sqlitex.Exec(conn, responseTimeQuery, nil,
cmd.EventID, responseID, d.Format(dbDateLayout), h)
if err != nil {
return
}
}
}
return CreateEventResponseResult{
ResponseID: responseID,
}, nil
}
type GetEventResponseQuery struct {
EventID string
ResponseID string
}
type GetEventResponseResult struct {
GuestName string
DateHours map[date.Date]map[int]struct{}
}
func (s *Store) GetEventResponse(ctx context.Context, query GetEventResponseQuery) (GetEventResponseResult, error) {
conn := s.pool.Get(ctx)
defer s.pool.Put(conn)
var found bool
var result GetEventResponseResult
const responseQuery = `
SELECT guest_name
FROM response
WHERE id = ? AND event_id = ?;`
var err = sqlitex.Exec(conn, responseQuery,
func(stmt *sqlite.Stmt) error {
found = true
result.GuestName = stmt.ColumnText(0)
return nil
}, query.ResponseID, query.EventID)
if err != nil {
return GetEventResponseResult{}, err
}
if !found {
return GetEventResponseResult{}, ErrNotFound
}
result.DateHours = make(map[date.Date]map[int]struct{})
const responseTimeQuery = `
SELECT date, time
FROM response_time
WHERE event_id = ? AND response_id = ?`
err = sqlitex.Exec(conn, responseTimeQuery,
func(stmt *sqlite.Stmt) error {
d, err := date.Parse(dbDateLayout, stmt.ColumnText(0))
if err != nil {
return err
}
if result.DateHours[d] == nil {
result.DateHours[d] = make(map[int]struct{})
}
result.DateHours[d][stmt.ColumnInt(1)] = struct{}{}
return nil
}, query.EventID, query.ResponseID)
if err != nil {
return GetEventResponseResult{}, err
}
return result, nil
}

302
internal/back/store_test.go Normal file
View File

@ -0,0 +1,302 @@
package back_test
import (
"context"
"math/rand"
"sort"
"testing"
"time"
"github.com/matryer/is"
"github.com/rickb777/date"
"gitlab.codemonkeysoftware.net/b/henwen/internal/back"
)
func TestNewMemoryStore(t *testing.T) {
is := is.New(t)
store, err := back.NewMemoryStore(back.SecureGenString)
is.NoErr(err)
is.True(store != nil)
store.Close()
}
func TestCreateEvent(t *testing.T) {
is := is.New(t)
store, err := back.NewMemoryStore(back.SecureGenString)
is.NoErr(err)
defer store.Close()
earliest := date.New(2020, 4, 5)
latest := date.New(2020, 4, 5)
const name = "abc"
const description = "def"
createResult, err := store.CreateEvent(context.Background(), back.CreateEventCommand{
Name: name,
Description: description,
Earliest: earliest,
Latest: latest,
})
is.NoErr(err)
metadataResult, err := store.GetEventMetadata(context.Background(), back.GetEventMetadataQuery{
EventID: createResult.EventID,
})
is.NoErr(err)
is.Equal(metadataResult.Name, name)
is.Equal(metadataResult.Description, description)
is.True(metadataResult.Earliest.Equal(earliest))
is.True(metadataResult.Latest.Equal(latest))
}
func TestGetEventResponseSummary(t *testing.T) {
store, err := back.NewMemoryStore(back.SecureGenString)
is.New(t).NoErr(err)
defer store.Close()
createEvent := func(is *is.I) (eventID string) {
event, err := store.CreateEvent(context.Background(), back.CreateEventCommand{
Name: "blah",
Description: "stuff happening",
Earliest: date.Today(),
Latest: date.Today().Add(1),
})
is.NoErr(err)
return event.EventID
}
t.Run("does not include responses to other events", func(t *testing.T) {
is := is.New(t)
d := date.New(1999, 12, 31)
wrongEvent, err := store.CreateEvent(context.Background(), back.CreateEventCommand{
Name: "Beach party",
Description: "Come on in! The water's fine!",
Earliest: d,
Latest: d,
})
is.NoErr(err)
_, err = store.CreateEventResponse(context.Background(), back.CreateEventResponseCommand{
EventID: wrongEvent.EventID,
GuestName: "Matt Hooper",
DateHours: map[date.Date]map[int]struct{}{d: {0: {}}},
})
is.NoErr(err)
summary, err := store.GetEventResponseSummary(context.Background(), back.GetEventResponseSummaryQuery{
EventID: createEvent(is),
})
is.NoErr(err)
is.Equal(summary, back.GetEventResponseSummaryResult{
RespondentNames: nil,
PerHourCounts: map[date.Date]map[int]int{},
})
})
t.Run("RespondentNames lists all respondents alphabetically", func(t *testing.T) {
is := is.New(t)
var names = []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"}
rand.Shuffle(len(names), func(i, j int) {
names[i], names[j] = names[j], names[i]
})
d := date.New(1963, time.May, 1)
event, err := store.CreateEvent(context.Background(), back.CreateEventCommand{
Name: "It's my party!",
Description: "I can cry if I want to.",
Latest: d,
Earliest: d,
})
is.NoErr(err)
for _, name := range names {
_, err = store.CreateEventResponse(context.Background(), back.CreateEventResponseCommand{
EventID: event.EventID,
GuestName: name,
})
is.NoErr(err)
}
summary, err := store.GetEventResponseSummary(context.Background(), back.GetEventResponseSummaryQuery{
EventID: event.EventID,
})
is.NoErr(err)
sort.Strings(names)
is.Equal(summary.RespondentNames, names)
})
t.Run("PerHourCounts counts votes per hour", func(t *testing.T) {
is := is.New(t)
dayOne := date.New(2012, 12, 21)
dayTwo := dayOne.Add(1)
createResult, err := store.CreateEvent(context.Background(), back.CreateEventCommand{
Name: "Apocalypse",
Description: "It's over.",
Earliest: dayOne,
Latest: dayTwo,
})
is.NoErr(err)
_, err = store.CreateEventResponse(context.Background(), back.CreateEventResponseCommand{
EventID: createResult.EventID,
GuestName: "Thanos",
DateHours: map[date.Date]map[int]struct{}{
dayOne: {0: {}, 1: {}, 2: {}, 3: {}},
dayTwo: {1: {}, 2: {}, 3: {}, 4: {}},
},
})
is.NoErr(err)
_, err = store.CreateEventResponse(context.Background(), back.CreateEventResponseCommand{
EventID: createResult.EventID,
GuestName: "Magog",
DateHours: map[date.Date]map[int]struct{}{
dayOne: {1: {}, 2: {}, 3: {}, 4: {}},
dayTwo: {2: {}, 3: {}, 4: {}, 5: {}},
},
})
is.NoErr(err)
summary, err := store.GetEventResponseSummary(context.Background(), back.GetEventResponseSummaryQuery{
EventID: createResult.EventID,
})
is.NoErr(err)
is.Equal(summary.PerHourCounts, map[date.Date]map[int]int{
dayOne: {0: 1, 1: 2, 2: 2, 3: 2, 4: 1},
dayTwo: {1: 1, 2: 2, 3: 2, 4: 2, 5: 1},
})
})
}
func TestCreateEventResponse(t *testing.T) {
store, err := back.NewMemoryStore(back.SecureGenString)
is.New(t).NoErr(err)
defer store.Close()
createEvent := func(is *is.I) (eventID string) {
event, err := store.CreateEvent(context.Background(), back.CreateEventCommand{
Name: "blah",
Description: "stuff happening",
Earliest: date.Today(),
Latest: date.Today().Add(1),
})
is.NoErr(err)
return event.EventID
}
t.Run("saves GuestName", func(t *testing.T) {
is := is.New(t)
eventID := createEvent(is)
const guestName = "Etaoin Shrdlu"
createResult, err := store.CreateEventResponse(context.Background(), back.CreateEventResponseCommand{
EventID: eventID,
GuestName: guestName,
})
is.NoErr(err)
getResult, err := store.GetEventResponse(context.Background(), back.GetEventResponseQuery{
EventID: eventID,
ResponseID: createResult.ResponseID,
})
is.NoErr(err)
is.Equal(getResult.GuestName, guestName)
})
t.Run("DateHours saves multiple times on multiple dates", func(t *testing.T) {
is := is.New(t)
earliest := date.Today()
latest := earliest.Add(1)
event, err := store.CreateEvent(context.Background(), back.CreateEventCommand{
Name: "blah",
Description: "stuff happening",
Earliest: earliest,
Latest: latest,
})
is.NoErr(err)
var dateHours = map[date.Date]map[int]struct{}{
earliest: {1: {}, 3: {}, 5: {}, 7: {}},
latest: {2: {}, 4: {}, 6: {}},
}
createResponseResult, err := store.CreateEventResponse(context.Background(), back.CreateEventResponseCommand{
EventID: event.EventID,
GuestName: "Etaoin Shrdlu",
DateHours: dateHours,
})
is.NoErr(err)
response, err := store.GetEventResponse(context.Background(), back.GetEventResponseQuery{
EventID: event.EventID,
ResponseID: createResponseResult.ResponseID,
})
is.NoErr(err)
is.Equal(dateHours, response.DateHours)
})
}
func TestAuthorizeEventAdmin(t *testing.T) {
store, err := back.NewMemoryStore(back.SecureGenString)
is.New(t).NoErr(err)
defer store.Close()
t.Run("returns ErrUnauthorized if admin code is wrong", func(t *testing.T) {
is := is.New(t)
event, err := store.CreateEvent(context.Background(), back.CreateEventCommand{
Name: "my event",
Description: "stuff happening",
Earliest: date.Today(),
Latest: date.Today(),
})
is.NoErr(err)
badAdminCode := event.AdminCode + "x"
err = store.AuthorizeEventAdmin(context.Background(), back.CheckEventAdminCodeQuery{
EventID: event.EventID,
AdminCode: badAdminCode,
})
is.Equal(err, back.UnauthorizedError{
EventID: event.EventID,
AdminCode: badAdminCode,
})
})
t.Run("return ErrUnauthorized if event does not exist", func(t *testing.T) {
is := is.New(t)
randString, err := back.SecureGenString(10)
is.NoErr(err)
err = store.AuthorizeEventAdmin(context.Background(), back.CheckEventAdminCodeQuery{
EventID: randString,
AdminCode: randString,
})
is.Equal(err, back.UnauthorizedError{
EventID: randString,
AdminCode: randString,
})
})
t.Run("returns nil if admin code is correct", func(t *testing.T) {
is := is.New(t)
event, err := store.CreateEvent(context.Background(), back.CreateEventCommand{
Name: "my event",
Description: "stuff happening",
Earliest: date.Today(),
Latest: date.Today(),
})
is.NoErr(err)
err = store.AuthorizeEventAdmin(context.Background(), back.CheckEventAdminCodeQuery{
EventID: event.EventID,
AdminCode: event.AdminCode,
})
is.NoErr(err)
})
}

503
internal/front/server.go Normal file
View File

@ -0,0 +1,503 @@
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"