From fdc080a250151e2094328d0116184cabf67c4bcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vojt=C4=9Bch=20K=C3=A1n=C4=9B?= Date: Sat, 6 Feb 2021 21:17:31 +0100 Subject: [PATCH] Implement switching to next question --- cmd/web/handlers.go | 34 +++++++++++++++++- cmd/web/main.go | 1 + pkg/model/ent/schema/askedQuestion.go | 41 +++++++++++++++++++++ pkg/model/ent/schema/question.go | 3 +- pkg/model/ent/schema/session.go | 8 ++--- pkg/model/model.go | 51 ++++++++++++++++++++++++++- ui/html/game.page.tmpl.html | 5 +-- 7 files changed, 130 insertions(+), 13 deletions(-) create mode 100644 pkg/model/ent/schema/askedQuestion.go diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index 8ea6c87..632776c 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -55,7 +55,7 @@ func (app *application) play(w http.ResponseWriter, r *http.Request, params http app.serverError(w, err) return } - http.Redirect(w, r, "/game/" + url.PathEscape(player.ID.String()), http.StatusSeeOther) + http.Redirect(w, r, "/game/"+url.PathEscape(player.ID.String()), http.StatusSeeOther) return } else if errors.Is(err, model.NoSuchEntity) { app.clientError(w, http.StatusNotFound) @@ -94,3 +94,35 @@ func (app *application) game(w http.ResponseWriter, r *http.Request, params http return } } + +func (app *application) nextQuestion(w http.ResponseWriter, r *http.Request, params httprouter.Params) { + var playerUid uuid.UUID + if uid, err := uuid.Parse(params.ByName("playerUid")); err == nil { + playerUid = uid + } else { + app.clientError(w, http.StatusBadRequest) + return + } + + if player, err := app.model.GetPlayerWithSessionAndGame(playerUid, r.Context()); err == nil { + var sessionId = player.Edges.Session.ID + if err := app.model.NextQuestion(sessionId, r.Context()); err == nil { + if su, err := app.model.GetQuestionStateUpdate(sessionId, r.Context()); err == nil { + app.rtClients.SendToAll(sessionId, su) + } else { + app.serverError(w, err) + return + } + } else { + app.serverError(w, err) + return + } + } else if ent.IsNotFound(err) { + app.infoLog.Println(playerUid) + app.clientError(w, http.StatusNotFound) + return + } else { + app.serverError(w, err) + return + } +} diff --git a/cmd/web/main.go b/cmd/web/main.go index fd5b9d6..59a5f00 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -68,6 +68,7 @@ func main() { mux.POST("/play/:code", app.play) mux.POST("/organise/:code", app.play) mux.GET("/game/:playerUid", app.game) + mux.POST("/game/:playerUid/rpc/next", app.nextQuestion) mux.GET("/ws/:playerUid", app.processWebSocket) diff --git a/pkg/model/ent/schema/askedQuestion.go b/pkg/model/ent/schema/askedQuestion.go new file mode 100644 index 0000000..1b10357 --- /dev/null +++ b/pkg/model/ent/schema/askedQuestion.go @@ -0,0 +1,41 @@ +package schema + +import ( + "github.com/facebook/ent" + "github.com/facebook/ent/schema/edge" + "github.com/facebook/ent/schema/field" +) + +type AskedQuestion struct { + ent.Schema +} + +func (AskedQuestion) Fields() []ent.Field { + return []ent.Field{ + field.Time("asked").Immutable(), + field.Time("ended"), + } +} + +func (AskedQuestion) Indexes() []ent.Index { + return []ent.Index{} +} + +func (AskedQuestion) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("session", Session.Type). + Ref("askedQuestions"). + Unique(). + Required(), + edge.From("question", Question.Type). + Ref("asked"). + Unique(). + Required(), + } +} + +func (AskedQuestion) Config() ent.Config { + return ent.Config{ + Table: "asked_questions", + } +} diff --git a/pkg/model/ent/schema/question.go b/pkg/model/ent/schema/question.go index de62b37..a072f23 100644 --- a/pkg/model/ent/schema/question.go +++ b/pkg/model/ent/schema/question.go @@ -17,6 +17,7 @@ func (Question) Fields() []ent.Field { field.UUID("id", uuid.New()).Immutable(), field.Text("title").MaxLen(256).MinLen(1), field.Int("order"), + field.Uint64("defaultLength"), // in milliseconds } } @@ -33,7 +34,7 @@ func (Question) Edges() []ent.Edge { Unique(). Required(), edge.To("choices", Choice.Type), - edge.To("current_sessions", Session.Type), + edge.To("asked", AskedQuestion.Type), } } diff --git a/pkg/model/ent/schema/session.go b/pkg/model/ent/schema/session.go index b67f899..75a8e9b 100644 --- a/pkg/model/ent/schema/session.go +++ b/pkg/model/ent/schema/session.go @@ -16,14 +16,12 @@ func (Session) Fields() []ent.Field { field.UUID("id", uuid.New()).Immutable(), field.Time("created").Immutable(), field.Time("started").Nillable().Optional(), // TODO remove? - field.Time("current_question_until").Nillable().Optional(), field.String("code").MinLen(6).MaxLen(6).Immutable().Unique(), } } func (Session) Indexes() []ent.Index { - return []ent.Index{ - } + return []ent.Index{} } func (Session) Edges() []ent.Edge { @@ -33,9 +31,7 @@ func (Session) Edges() []ent.Edge { Unique(). Required(), edge.To("players", Player.Type), - edge.From("current_question", Question.Type). - Ref("current_sessions"). - Unique(), + edge.To("askedQuestions", AskedQuestion.Type), } } diff --git a/pkg/model/model.go b/pkg/model/model.go index ffe5bc5..8e2734e 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -1,5 +1,7 @@ package model +//TODO loose transaction levels wherever possible + import ( "context" "database/sql" @@ -7,6 +9,8 @@ import ( "github.com/google/uuid" "time" "vkane.cz/tinyquiz/pkg/model/ent" + "vkane.cz/tinyquiz/pkg/model/ent/askedquestion" + "vkane.cz/tinyquiz/pkg/model/ent/game" "vkane.cz/tinyquiz/pkg/model/ent/player" "vkane.cz/tinyquiz/pkg/model/ent/question" "vkane.cz/tinyquiz/pkg/model/ent/session" @@ -134,7 +138,8 @@ func (m *Model) GetQuestionStateUpdate(sessionId uuid.UUID, c context.Context) ( } defer tx.Commit() - if q, err := tx.Question.Query().Where(question.HasCurrentSessionsWith(session.ID(sessionId))).WithChoices().Only(c); err == nil { + if aq, err := tx.Question.Query().WithChoices().Where(question.HasAskedWith(askedquestion.HasSessionWith(session.ID(sessionId)))).QueryAsked().WithQuestion(func(q *ent.QuestionQuery) { q.WithChoices() }).Order(ent.Desc(askedquestion.FieldAsked)).First(c); err == nil { + var q = aq.Edges.Question var qu rtcomm.QuestionUpdate qu.Title = q.Title qu.Answers = make([]rtcomm.Answer, 0, len(q.Edges.Choices)) @@ -166,3 +171,47 @@ func (m *Model) GetFullStateUpdate(sessionId uuid.UUID, c context.Context) (rtco } return su, nil } + +var NoNextQuestion = errors.New("there is no next question") // TODO fill + +// TODO retry on serialization failure +// TODO validate sessionId +func (m *Model) NextQuestion(sessionId uuid.UUID, c context.Context) error { + tx, err := m.c.BeginTx(c, &sql.TxOptions{ + Isolation: sql.LevelSerializable, + }) + if err != nil { + return err + } + // TODO rollback only if not yet committed + defer tx.Rollback() + + var now = time.Now() + + var query = tx.Question.Query().Where(question.HasGameWith(game.HasSessionsWith(session.ID(sessionId)))).Order(ent.Asc(question.FieldOrder)) + + if current, err := tx.AskedQuestion.Query().Where(askedquestion.HasSessionWith(session.ID(sessionId))).WithQuestion().Order(ent.Desc(askedquestion.FieldAsked)).First(c); err == nil { + query.Where(question.OrderGT(current.Edges.Question.Order)) + // TODO make sure we do not extend the deadline by slow processing + if current.Ended.After(now) { + if _, err := current.Update().SetEnded(now).Save(c); err != nil { + return err + } + } + } else if !ent.IsNotFound(err) { + return err + } + + if next, err := query.First(c); err == nil { + if _, err := tx.AskedQuestion.Create().SetAsked(now).SetSessionID(sessionId).SetQuestion(next).SetEnded(now.Add(time.Duration(int64(next.DefaultLength)) * time.Millisecond)).Save(c); err != nil { + return err + } + } else if ent.IsNotFound(err) { + return NoNextQuestion + } else { + return err + } + + tx.Commit() + return nil +} diff --git a/ui/html/game.page.tmpl.html b/ui/html/game.page.tmpl.html index b1ee353..e40299d 100644 --- a/ui/html/game.page.tmpl.html +++ b/ui/html/game.page.tmpl.html @@ -45,10 +45,7 @@ document.addEventListener("DOMContentLoaded", () => { const next = document.querySelector('#controls .next'); next.addEventListener("click", () => { - let url = window.location.path + '/rpc/next'; - if (questionSection.dataset.id) { - url += '?current=' + encodeURIComponent(questionSection.dataset.id); - } + let url = window.location.pathname + '/rpc/next'; fetch(url, {method: "POST"}) .catch(() => { console.warn("Setting next question failed")