Move front and back into internal
This commit is contained in:
27
internal/back/genstring.go
Normal file
27
internal/back/genstring.go
Normal 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
430
internal/back/store.go
Normal 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
302
internal/back/store_test.go
Normal 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
503
internal/front/server.go
Normal 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"
|
Reference in New Issue
Block a user