diff --git a/cmd/web/handlers.go b/cmd/web/handlers.go index 632776c..c30ffa1 100644 --- a/cmd/web/handlers.go +++ b/cmd/web/handlers.go @@ -126,3 +126,33 @@ func (app *application) nextQuestion(w http.ResponseWriter, r *http.Request, par return } } + +func (app *application) answer(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 + } + + var choiceUid uuid.UUID + if uid, err := uuid.Parse(params.ByName("choiceUid")); err == nil { + choiceUid = uid + } else { + app.clientError(w, http.StatusBadRequest) + return + } + + if _, err := app.model.SaveAnswer(playerUid, choiceUid, r.Context()); err == nil { + // TODO notify organisers + w.WriteHeader(http.StatusCreated) // TODO or StatusNoContent? + return + } else if errors.Is(err, model.NoSuchEntity) { + 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 59a5f00..88efc41 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -69,6 +69,7 @@ func main() { mux.POST("/organise/:code", app.play) mux.GET("/game/:playerUid", app.game) mux.POST("/game/:playerUid/rpc/next", app.nextQuestion) + mux.POST("/game/:playerUid/answers/:choiceUid", app.answer) mux.GET("/ws/:playerUid", app.processWebSocket) diff --git a/pkg/model/model.go b/pkg/model/model.go index 8e2734e..b08c1a5 100644 --- a/pkg/model/model.go +++ b/pkg/model/model.go @@ -9,7 +9,9 @@ import ( "github.com/google/uuid" "time" "vkane.cz/tinyquiz/pkg/model/ent" + "vkane.cz/tinyquiz/pkg/model/ent/answer" "vkane.cz/tinyquiz/pkg/model/ent/askedquestion" + "vkane.cz/tinyquiz/pkg/model/ent/choice" "vkane.cz/tinyquiz/pkg/model/ent/game" "vkane.cz/tinyquiz/pkg/model/ent/player" "vkane.cz/tinyquiz/pkg/model/ent/question" @@ -215,3 +217,51 @@ func (m *Model) NextQuestion(sessionId uuid.UUID, c context.Context) error { tx.Commit() return nil } + +var QuestionClosed = errors.New("the deadline for answers to this question has passed") +var AlreadyAnswered = errors.New("the player has already answered the question") + +func (m *Model) SaveAnswer(playerId uuid.UUID, choiceId uuid.UUID, c context.Context) (*ent.Answer, error) { + tx, err := m.c.BeginTx(c, &sql.TxOptions{ + Isolation: sql.LevelSerializable, + }) + if err != nil { + return nil, err + } + defer tx.Rollback() + + // check whether the player could pick this choice + if exists, err := tx.Choice.Query().Where(choice.HasQuestionWith(question.HasGameWith(game.HasSessionsWith(session.HasPlayersWith(player.ID(playerId))))), choice.ID(choiceId)).Exist(c); err == nil && !exists { + return nil, NoSuchEntity + } else if err != nil { + return nil, err + } + + var q *ent.Question + // find the most recent question + if q, err = tx.Player.Query().Where(player.ID(playerId)).QuerySession().QueryGame().QueryQuestions().Where(question.HasAsked()).WithAsked().Order(ent.Desc(question.FieldOrder)).First(c); ent.IsNotFound(err) { + return nil, NoSuchEntity + } else if err != nil { + return nil, err + } + + // check if the question is open + // Asked[0] is guaranteed to exist thanks to the previous query + if !q.Edges.Asked[0].Ended.After(time.Now()) { + return nil, QuestionClosed + } + + // check the player has not answered yet + if exists, err := tx.Answer.Query().Where(answer.HasAnswererWith(player.ID(playerId)), answer.HasChoiceWith(choice.HasQuestionWith(question.ID(q.ID)))).Exist(c); err != nil { + return nil, err + } else if exists { + return nil, AlreadyAnswered + } + + if a, err := tx.Answer.Create().SetID(uuid.New()).SetAnswered(time.Now()).SetChoiceID(choiceId).SetAnswererID(playerId).Save(c); err == nil { + tx.Commit() + return a, nil + } else { + return nil, err + } +} diff --git a/ui/html/game.page.tmpl.html b/ui/html/game.page.tmpl.html index ddff28e..fb524b9 100644 --- a/ui/html/game.page.tmpl.html +++ b/ui/html/game.page.tmpl.html @@ -45,7 +45,7 @@ document.addEventListener("DOMContentLoaded", () => { const next = document.querySelector('#controls .next'); next.addEventListener("click", () => { - let url = window.location.pathname + '/rpc/next'; + const url = window.location.pathname + '/rpc/next'; fetch(url, {method: "POST"}) .catch(() => { console.warn("Setting next question failed") @@ -87,9 +87,24 @@ const organiser = document.body.classList.contains('organiser'); for (const answer of data.question.answers) { const answerClone = answerTemplate.content.cloneNode(true); - answerClone.querySelector('.answer').innerText = answer.title; + const button = answerClone.querySelector('.answer'); + button.innerText = answer.title; + button.dataset.id = answer.id; if (organiser) { - answerClone.querySelector('.answer').disabled = true; + button.disabled = true; + } else { + button.addEventListener('click', (e) => { + const id = e.target.dataset.id; + const url = window.location.pathname + '/answers/' + encodeURIComponent(id); + fetch(url, {method: 'POST'}) + .then(() => { + e.target.classList.add('selected'); + for (const button of document.getElementsByClassName('answer')) { + button.disabled = true; + } + }) + .catch((err) => console.error(err)) // TODO proper error handling + }); } answers.appendChild(answerClone); } diff --git a/ui/static/game.css b/ui/static/game.css index f2a9029..279af5b 100644 --- a/ui/static/game.css +++ b/ui/static/game.css @@ -46,6 +46,11 @@ font-size: 2rem; } +.answer.selected { + font-weight: bold; + text-shadow: 2px 1px 5px #0003; +} + /*body.organiser #question > .answers { display: none; }*/