diff --git a/back/store.go b/back/store.go index 7a89be4..753250b 100644 --- a/back/store.go +++ b/back/store.go @@ -54,7 +54,7 @@ func NewMemoryStore(genString GenString) (*Store, error) { pool: pool, genString: genString, } - log.Println("creating schema") + err = store.createSchema() if err != nil { defer pool.Close() @@ -84,13 +84,14 @@ const schema = ` id INTEGER PRIMARY KEY, alpha_id TEXT NOT NULL, event_id INTEGER NOT NULL, + guest_name TEXT NOT NULL, UNIQUE (alpha_id), FOREIGN KEY (event_id) REFERENCES event(id) ); CREATE TABLE response_time ( - response_id INTEGER PRIMARY KEY, + response_id INTEGER NOT NULL, date DATE NOT NULL, time INTEGER NOT NULL, @@ -103,7 +104,7 @@ const schema = ` BEGIN SELECT CASE - WHEN NEW.date BETWEEN event.earliest_date AND event.latest_date + WHEN NEW.date NOT BETWEEN event.earliest_date AND event.latest_date THEN RAISE(ABORT, 'response date is out of range') END FROM response @@ -213,36 +214,58 @@ func (s *Store) GetEventMetadata(ctx context.Context, query GetEventMetadataQuer return result, nil } -type GetEventResponsesQuery struct { +type GetEventResponseSummaryQuery struct { AlphaID string } -type GetEventResponsesResult struct { +type GetEventResponseSummaryResult struct { TotalResponses int } -func (s *Store) GetEventResponses(ctx context.Context, query GetEventResponsesQuery) (GetEventResponsesResult, error) { +func (s *Store) GetEventResponseSummary(ctx context.Context, query GetEventResponseSummaryQuery) (GetEventResponseSummaryResult, error) { conn := s.pool.Get(ctx) defer s.pool.Put(conn) - var result GetEventResponsesResult - const dbQuery = ` + var found bool + var eventID int + + const eventIDQuery = ` + SELECT id + FROM event + WHERE alpha_id = ?` + var err = sqlitex.Exec(conn, eventIDQuery, + func(stmt *sqlite.Stmt) error { + found = true + eventID = stmt.ColumnInt(0) + return nil + }, query.AlphaID) + if err != nil { + return GetEventResponseSummaryResult{}, err + } + if !found { + return GetEventResponseSummaryResult{}, errors.New("event not found") + } + + var result GetEventResponseSummaryResult + const responseCountQuery = ` SELECT COUNT(*) FROM response - JOIN event ON response.event_id = event.id - WHERE event.alpha_id = ?;` - err := sqlitex.Exec(conn, dbQuery, + WHERE event_id = ?;` + err = sqlitex.Exec(conn, responseCountQuery, func(stmt *sqlite.Stmt) error { result.TotalResponses = stmt.ColumnInt(0) return nil - }, query.AlphaID) - // TODO return an error if the event does not exist? - return result, err + }, eventID) + if err != nil { + return GetEventResponseSummaryResult{}, err + } + return result, nil } type CreateEventResponseCommand struct { EventAlphaID string - DateHours map[date.Date]int + GuestName string + DateHours map[date.Date]map[int]struct{} } type CreateEventResponseResult struct { @@ -258,22 +281,95 @@ func (s *Store) CreateEventResponse(ctx context.Context, cmd CreateEventResponse defer sqlitex.Save(conn)(&err) responseAlphaID, err := s.genString(responseAlphaIDLength) if err != nil { - return CreateEventResponseResult{}, err + return } - // Create response const responseQuery = ` - INSERT INTO response(event_id, alpha_id) - SELECT event.id AS event_id, ? AS alpha_id + INSERT INTO response(event_id, alpha_id, guest_name) + SELECT event.id AS event_id, ? AS alpha_id, ? as guest_name FROM event WHERE event.alpha_id = ?;` - err = sqlitex.Exec(conn, responseQuery, nil, responseAlphaID, cmd.EventAlphaID) + err = sqlitex.Exec(conn, responseQuery, nil, responseAlphaID, cmd.GuestName, cmd.EventAlphaID) if err != nil { - return CreateEventResponseResult{}, err + return + } + + const responseTimeQuery = ` + INSERT INTO response_time(response_id, date, time) + SELECT response.id AS response_id, ? AS date, ? AS time + FROM response + WHERE response.alpha_id = ?;` + for d, hs := range cmd.DateHours { + for h := range hs { + err = sqlitex.Exec(conn, responseTimeQuery, nil, d.Format(dbDateLayout), h, responseAlphaID) + if err != nil { + return + } + } } - // TODO Create response times return CreateEventResponseResult{ ResponseAlphaID: responseAlphaID, }, nil } + +type GetEventResponseQuery struct { + EventAlphaID string + ResponseAlphaID 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 + var responseID int + + const responseQuery = ` + SELECT guest_name, response.id + FROM response + JOIN event ON response.event_id = event.id + WHERE response.alpha_id = ? AND event.alpha_id = ?;` + var err = sqlitex.Exec(conn, responseQuery, + func(stmt *sqlite.Stmt) error { + found = true + result.GuestName = stmt.ColumnText(0) + responseID = stmt.ColumnInt(1) + return nil + }, query.ResponseAlphaID, query.EventAlphaID) + if err != nil { + return GetEventResponseResult{}, err + } + if !found { + // TODO return a constant or typed error + return GetEventResponseResult{}, errors.New("not found") + } + + result.DateHours = make(map[date.Date]map[int]struct{}) + const responseTimeQuery = ` + SELECT date, time + FROM response_time + WHERE 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 + }, responseID) + if err != nil { + return GetEventResponseResult{}, err + } + return result, nil +} diff --git a/back/store_test.go b/back/store_test.go index b299162..36c338a 100644 --- a/back/store_test.go +++ b/back/store_test.go @@ -45,9 +45,10 @@ func TestCreateEvent(t *testing.T) { is.True(metadataResult.Latest.Equal(latest)) } -func TestGetEventResponses(t *testing.T) { +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{ @@ -61,7 +62,7 @@ func TestGetEventResponses(t *testing.T) { } getTotalResponses := func(is *is.I, eventID string) int { - responses, err := store.GetEventResponses(context.Background(), back.GetEventResponsesQuery{ + responses, err := store.GetEventResponseSummary(context.Background(), back.GetEventResponseSummaryQuery{ AlphaID: eventID, }) is.NoErr(err) @@ -99,3 +100,74 @@ func TestGetEventResponses(t *testing.T) { is.Equal(getTotalResponses(is, eventID), 1) }) } + +func pending(t *testing.T) { + t.Fatalf("pending") +} + +func TestCreateEventResponse(t *testing.T) { + store, err := back.NewMemoryStore(back.SecureGenString) + is.New(t).NoErr(err) + + 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.AlphaID + } + + 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{ + EventAlphaID: eventID, + GuestName: guestName, + }) + is.NoErr(err) + + getResult, err := store.GetEventResponse(context.Background(), back.GetEventResponseQuery{ + EventAlphaID: eventID, + ResponseAlphaID: createResult.ResponseAlphaID, + }) + 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{ + EventAlphaID: event.AlphaID, + GuestName: "Etaoin Shrdlu", + DateHours: dateHours, + }) + is.NoErr(err) + + response, err := store.GetEventResponse(context.Background(), back.GetEventResponseQuery{ + EventAlphaID: event.AlphaID, + ResponseAlphaID: createResponseResult.ResponseAlphaID, + }) + is.NoErr(err) + is.Equal(dateHours, response.DateHours) + }) +} diff --git a/front/server.go b/front/server.go index 98b25dd..6e4ced0 100644 --- a/front/server.go +++ b/front/server.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "log" "net/http" "net/url" "strconv" @@ -69,6 +70,7 @@ var timeLabels = []string{ } type voteState struct { + eventAlphaID string name string earliest, latest date.Date } @@ -90,15 +92,22 @@ func voteForm(disabled bool, st voteState) hm.Term { var row = hm.Terms{ e.Td()(hm.Text(timeLabels[hour])), } - for day := 0; day < len(dates); day++ { - row = append(row, e.Td()(e.Input(a.Type("checkbox"), a.Disabled(disabled)))) + for _, day := range dates { + value := fmt.Sprintf("%8s-%02d", day.Format("20060102"), hour) + row = append(row, e.Td()(e.Input( + a.Name("slot"), + 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.Label()(hm.Text("What's your name?")), + e.Input(a.Type("hidden"), a.Name(keyEventID), a.Value(st.eventAlphaID)), + e.Label(a.For(fieldNameGuestName))(hm.Text("What's your name?")), e.Br(), - e.Input(a.Size(40), a.Value(st.name), a.Disabled(disabled)), + 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), @@ -130,11 +139,50 @@ func (h *handler) handleVote(w http.ResponseWriter, r *http.Request) { } func (h *handler) handleDoVote(w http.ResponseWriter, r *http.Request) { + err := r.ParseForm() + if err != nil { + log.Println(err) + http.Error(w, "invalid form values", http.StatusBadRequest) + return + } + + cmd := back.CreateEventResponseCommand{ + EventAlphaID: r.URL.Query().Get(keyEventID), + DateHours: make(map[date.Date]map[int]struct{}), + } + for _, slot := range r.PostForm["slot"] { + var dateString string + var hour int + _, err := fmt.Sscanf(slot, "%8s-%02d", &dateString, &hour) + if err != nil { + log.Println(err) + http.Error(w, "invalid form values", http.StatusBadRequest) + return + } + d, err := date.Parse("20060102", dateString) + if err != nil { + log.Println(err) + http.Error(w, "invalid form values", http.StatusBadRequest) + return + } + if cmd.DateHours[d] == nil { + cmd.DateHours[d] = make(map[int]struct{}) + } + cmd.DateHours[d][hour] = struct{}{} + } + _, err = h.store.CreateEventResponse(r.Context(), cmd) + if err != nil { + log.Println(err) + // TODO handle not found + http.Error(w, "internal server error", http.StatusInternalServerError) + return + } + // TODO Consider redirecting to vote edit page once that exists. // TODO Use actual data. state := voteState{ - name: "Suzie Q", + name: r.Form.Get(fieldNameGuestName), earliest: date.New(2006, time.May, 3), latest: date.New(2006, time.May, 8), } @@ -252,7 +300,7 @@ func (h *handler) handleAdmin(w http.ResponseWriter, r *http.Request) { return } - responses, err := h.store.GetEventResponses(context.Background(), back.GetEventResponsesQuery{ + responses, err := h.store.GetEventResponseSummary(context.Background(), back.GetEventResponseSummaryQuery{ AlphaID: eventID, }) if err != nil { @@ -326,6 +374,7 @@ const ( fieldNameLatest = "latestDate" fieldNameEventName = "eventName" fieldNameDescription = "eventDescription" + fieldNameGuestName = "guestName" ) const keyEventID = "event_id"