From 011420ef04f32f2c5e74ab9d6ec8e401cba08deb Mon Sep 17 00:00:00 2001 From: Dionysos <75300347+ice-dionysos@users.noreply.github.com> Date: Fri, 24 Nov 2023 01:34:54 +0100 Subject: [PATCH 1/4] kyc: add quiz --- cmd/eskimo-hut/contract.go | 10 +- cmd/eskimo-hut/eskimo_hut.go | 3 + cmd/eskimo-hut/kyc.go | 111 +++--- kyc/quiz/.testdata/application.yaml | 30 ++ kyc/quiz/DDL.sql | 35 ++ kyc/quiz/contract.go | 80 ++++- kyc/quiz/quiz.go | 504 ++++++++++++++++++++++++++++ kyc/quiz/quiz_test.go | 363 ++++++++++++++++++++ 8 files changed, 1085 insertions(+), 51 deletions(-) create mode 100644 kyc/quiz/.testdata/application.yaml create mode 100644 kyc/quiz/DDL.sql create mode 100644 kyc/quiz/quiz_test.go diff --git a/cmd/eskimo-hut/contract.go b/cmd/eskimo-hut/contract.go index 98d31fd6..96341237 100644 --- a/cmd/eskimo-hut/contract.go +++ b/cmd/eskimo-hut/contract.go @@ -9,6 +9,7 @@ import ( "github.com/pkg/errors" emaillink "github.com/ice-blockchain/eskimo/auth/email_link" + kycquiz "github.com/ice-blockchain/eskimo/kyc/quiz" kycsocial "github.com/ice-blockchain/eskimo/kyc/social" "github.com/ice-blockchain/eskimo/users" ) @@ -142,9 +143,9 @@ type ( Authorization string `header:"Authorization" swaggerignore:"true" required:"true" allowForbiddenWriteOperation:"true" allowUnauthorized:"true"` } StartOrContinueKYCStep4SessionRequestBody struct { - SelectedOption *uint8 `form:"selectedOption" required:"true" swaggerignore:"true" example:"0"` Language string `form:"language" required:"true" swaggerignore:"true" example:"en"` - QuestionNumber uint8 `form:"questionNumber" required:"true" swaggerignore:"true" example:"11"` + QuestionNumber uint `form:"questionNumber" required:"true" swaggerignore:"true" example:"11"` + SelectedOption uint8 `form:"selectedOption" required:"true" swaggerignore:"true" example:"0"` } TryResetKYCStepsRequestBody struct { Authorization string `header:"Authorization" swaggerignore:"true" required:"true" example:"some token"` @@ -189,8 +190,10 @@ const ( noPendingLoginSessionErrorCode = "NO_PENDING_LOGIN_SESSION" quizAlreadyCompletedSuccessfullyErrorCode = "QUIZ_ALREADY_COMPLETED_SUCCESSFULLY" - questionAlreadyAnsweredErrorCode = "QUESTION_ALREADY_ANSWERED" quizNotAvailableErrorCode = "QUIZ_NOT_AVAILABLE" + quizAlreadyRunningErrorCode = "QUIZ_ALREADY_RUNNING" + quizUnknownQuestionNumErrorCode = "QUIZ_UNKNOWN_QUESTION_NUM" + quizExpiredErrorCode = "QUIZ_EXPIRED" socialKYCStepAlreadyCompletedSuccessfullyErrorCode = "SOCIAL_KYC_STEP_ALREADY_COMPLETED_SUCCESSFULLY" socialKYCStepNotAvailableErrorCode = "SOCIAL_KYC_STEP_NOT_AVAILABLE" @@ -211,6 +214,7 @@ type ( // | service implements server.State and is responsible for managing the state and lifecycle of the package. service struct { usersProcessor users.Processor + kycquiz kycquiz.Repository authEmailLinkClient emaillink.Client socialRepository kycsocial.Repository } diff --git a/cmd/eskimo-hut/eskimo_hut.go b/cmd/eskimo-hut/eskimo_hut.go index 74d1d638..da6387b3 100644 --- a/cmd/eskimo-hut/eskimo_hut.go +++ b/cmd/eskimo-hut/eskimo_hut.go @@ -11,6 +11,7 @@ import ( emaillink "github.com/ice-blockchain/eskimo/auth/email_link" "github.com/ice-blockchain/eskimo/cmd/eskimo-hut/api" + kycquiz "github.com/ice-blockchain/eskimo/kyc/quiz" "github.com/ice-blockchain/eskimo/kyc/social" "github.com/ice-blockchain/eskimo/users" appcfg "github.com/ice-blockchain/wintr/config" @@ -49,6 +50,7 @@ func (s *service) RegisterRoutes(router *server.Router) { func (s *service) Init(ctx context.Context, cancel context.CancelFunc) { s.usersProcessor = users.StartProcessor(ctx, cancel) + s.kycquiz = kycquiz.NewRepository(ctx, s.usersProcessor) s.authEmailLinkClient = emaillink.NewClient(ctx, s.usersProcessor, server.Auth(ctx)) s.socialRepository = social.New(ctx, s.usersProcessor) } @@ -62,6 +64,7 @@ func (s *service) Close(ctx context.Context) error { errors.Wrap(s.socialRepository.Close(), "could not close socialRepository"), errors.Wrap(s.authEmailLinkClient.Close(), "could not close authEmailLinkClient"), errors.Wrap(s.usersProcessor.Close(), "could not close usersProcessor"), + errors.Wrap(s.kycquiz.Close(), "could not close kyc.quiz"), ).ErrorOrNil() } diff --git a/cmd/eskimo-hut/kyc.go b/cmd/eskimo-hut/kyc.go index f3880feb..189200f1 100644 --- a/cmd/eskimo-hut/kyc.go +++ b/cmd/eskimo-hut/kyc.go @@ -4,11 +4,7 @@ package main import ( "context" - "fmt" - "math/rand" "slices" - "strings" - stdlibtime "time" "github.com/pkg/errors" @@ -17,7 +13,6 @@ import ( "github.com/ice-blockchain/eskimo/users" "github.com/ice-blockchain/wintr/log" "github.com/ice-blockchain/wintr/server" - "github.com/ice-blockchain/wintr/time" ) func (s *service) setupKYCRoutes(router *server.Router) { @@ -52,51 +47,79 @@ func (s *service) setupKYCRoutes(router *server.Router) { // @Failure 500 {object} server.ErrorResponse // @Failure 504 {object} server.ErrorResponse "if request times out" // @Router /kyc/startOrContinueKYCStep4Session/users/{userId} [POST]. -func (s *service) StartOrContinueKYCStep4Session( //nolint:gocritic,funlen,revive // . - _ context.Context, +func (s *service) StartOrContinueKYCStep4Session( //nolint:gocritic,funlen,revive,gocognit,gocyclo,cyclop // . + ctx context.Context, req *server.Request[StartOrContinueKYCStep4SessionRequestBody, kycquiz.Quiz], ) (*server.Response[kycquiz.Quiz], *server.Response[server.ErrorResponse]) { - //nolint:godox // . - // TODO add validations for "selectedOption" && "questionNumber". - // TODO if we don`t support a specific language, default to 'en'. - // TODO return 404 USER_NOT_FOUND if user is not found. - // TODO implement the proper logic for the use cases bellow. - if req.Data.QuestionNumber != 222 { //nolint:gomnd // . - switch rand.Intn(10) { //nolint:gosec,gomnd // . - case 0: - return server.OK(&kycquiz.Quiz{Result: kycquiz.FailureResult}), nil - case 1: - return server.OK(&kycquiz.Quiz{Result: kycquiz.SuccessResult}), nil - case 2: //nolint:gomnd // . - return nil, server.Conflict(errors.Errorf("question already answered, retry with fresh a call (222)"), questionAlreadyAnsweredErrorCode) + const ( + magicNumberQuizStart = 222 + defaultLanguage = "en" + ) + + // Handle the session start. + if req.Data.QuestionNumber == magicNumberQuizStart && req.Data.SelectedOption == magicNumberQuizStart { + var ( + err error + quiz *kycquiz.Quiz + ) + langLoop: + for _, lang := range []string{req.Data.Language, defaultLanguage} { + quiz, err = s.kycquiz.StartQuizSession(ctx, req.AuthenticatedUser.UserID, lang) + if err != nil { + switch { + case errors.Is(err, kycquiz.ErrUnknownLanguage): + continue langLoop + + case errors.Is(err, kycquiz.ErrUnknownUser): + return nil, server.NotFound(err, userNotFoundErrorCode) + + case errors.Is(err, kycquiz.ErrSessionIsAlreadyRunning): + return nil, server.ForbiddenWithCode(errors.Errorf("another quiz session is already running"), quizAlreadyRunningErrorCode) + + case errors.Is(err, kycquiz.ErrSessionFinished): + return nil, server.Conflict( + errors.Errorf("quiz session already finished successfully, ignore it and proceed with mining"), + quizAlreadyCompletedSuccessfullyErrorCode) + + case errors.Is(err, kycquiz.ErrSessionFinishedWithError): + return nil, server.Conflict(errors.Errorf("quiz session already finished with error"), quizNotAvailableErrorCode) + + default: + return nil, server.Unexpected(err) + } + } } + + return server.OK(quiz), nil } - switch rand.Intn(10) { //nolint:gosec,gomnd // . - case 0: - return nil, server.Conflict(errors.Errorf("quiz already finished successfully, ignore it and proceed with mining"), quizAlreadyCompletedSuccessfullyErrorCode) - case 1: - return nil, server.ForbiddenWithCode(errors.Errorf("quiz not available, ignore it and proceed with mining"), quizNotAvailableErrorCode) + + // Handle the session continuation. + session, err := s.kycquiz.ContinueQuizSession(ctx, req.AuthenticatedUser.UserID, req.Data.QuestionNumber, req.Data.SelectedOption) + if err != nil { + switch { + case errors.Is(err, kycquiz.ErrUnknownUser) || errors.Is(err, kycquiz.ErrUnknownSession): + return nil, server.NotFound(err, userNotFoundErrorCode) + + case errors.Is(err, kycquiz.ErrSessionFinished): + return nil, server.Conflict( + errors.Errorf("quiz session already finished successfully, ignore it and proceed with mining"), + quizAlreadyCompletedSuccessfullyErrorCode) + + case errors.Is(err, kycquiz.ErrSessionFinishedWithError): + return nil, server.Conflict(errors.Errorf("quiz session already finished with error"), quizNotAvailableErrorCode) + + case errors.Is(err, kycquiz.ErrUnknownQuestionNumber): + return nil, server.BadRequest(err, quizUnknownQuestionNumErrorCode) + + case errors.Is(err, kycquiz.ErrSessionExpired): + return nil, server.Conflict(errors.Errorf("this quiz session has expired, please start a new one"), quizExpiredErrorCode) + + default: + return nil, server.Unexpected(err) + } } - //nolint:lll // . - return server.OK(&kycquiz.Quiz{ - Progress: &kycquiz.Progress{ - ExpiresAt: time.New(stdlibtime.Now().Add(stdlibtime.Hour)), - NextQuestion: &kycquiz.Question{ - Options: []string{ - fmt.Sprintf("[%v]You don't need to do anything and the ice is mined automatically", strings.Repeat("bogus", rand.Intn(20))), //nolint:gosec,gomnd,lll // . - fmt.Sprintf("[%v]You need to check in every 24 hours by tapping the Ice button to begin your daily mining session", strings.Repeat("bogus", rand.Intn(20))), //nolint:gosec,gomnd,lll // . - fmt.Sprintf("[%v]Ice is not mined, but it turns out immediately after registration", strings.Repeat("bogus", rand.Intn(20))), //nolint:gosec,gomnd,lll // . - fmt.Sprintf("[%v]Ice is cool", strings.Repeat("bogus", rand.Intn(20))), //nolint:gosec,gomnd // . - }, - Number: uint8(11 + rand.Intn(20)), //nolint:gosec,gomnd // . - Text: fmt.Sprintf("[%v][%v] What are the major differences between Ice, Pi and Bee?", req.Data.Language, strings.Repeat("bogus", rand.Intn(20))), //nolint:gosec,gomnd,lll // . - }, - MaxQuestions: uint8(30 + rand.Intn(40)), //nolint:gosec,gomnd // . - CorrectAnswers: uint8(1 + rand.Intn(6)), //nolint:gosec,gomnd // . - IncorrectAnswers: uint8(1 + rand.Intn(3)), //nolint:gosec,gomnd // . - }, - }), nil + return server.OK(session), nil } // VerifySocialKYCStep godoc diff --git a/kyc/quiz/.testdata/application.yaml b/kyc/quiz/.testdata/application.yaml new file mode 100644 index 00000000..f32e7fa1 --- /dev/null +++ b/kyc/quiz/.testdata/application.yaml @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: ice License 1.0 + +development: true +logger: + encoder: console + level: debug + +users: &users + wintr/connectors/storage/v2: + runDDL: true + primaryURL: postgresql://root:pass@localhost:5432/ice + credentials: + user: root + password: pass + replicaURLs: + - postgresql://root:pass@localhost:5432/ice + +kyc/quiz: + maxSessionDurationSeconds: 600 + maxQuestionsPerSession: 3 + maxWrongAnswersPerSession: 1 + sessionCoolDownSeconds: 3600 + wintr/connectors/storage/v2: + runDDL: true + primaryURL: postgresql://root:pass@localhost:5432/ice + credentials: + user: root + password: pass + replicaURLs: + - postgresql://root:pass@localhost:5432/ice diff --git a/kyc/quiz/DDL.sql b/kyc/quiz/DDL.sql new file mode 100644 index 00000000..522a63f7 --- /dev/null +++ b/kyc/quiz/DDL.sql @@ -0,0 +1,35 @@ +-- SPDX-License-Identifier: ice License 1.0 + +create table if not exists questions +( + id bigint not null generated always as identity, + correct_option smallint not null, + options text[] not null, + language text not null, + question text not null, + unique (question, language), + primary key (language, id) +); + +create table if not exists failed_quizz_sessions +( + started_at timestamp not null, + ended_at timestamp not null, + skipped boolean not null default false, + questions bigint[] not null, + answers smallint[] not null, + user_id text not null references users (id) ON DELETE CASCADE, + language text not null, + primary key (user_id, started_at) +); + +create table if not exists quizz_sessions +( + started_at timestamp not null, + ended_at timestamp, + ended_successfully boolean not null default false, + questions bigint[] not null, + answers smallint[] not null, + user_id text primary key references users (id) ON DELETE CASCADE, + language text not null +); diff --git a/kyc/quiz/contract.go b/kyc/quiz/contract.go index 483218a2..26cb3c53 100644 --- a/kyc/quiz/contract.go +++ b/kyc/quiz/contract.go @@ -3,6 +3,13 @@ package quiz import ( + "context" + _ "embed" + "io" + stdlibtime "time" + + "github.com/ice-blockchain/eskimo/users" + "github.com/ice-blockchain/wintr/connectors/storage/v2" "github.com/ice-blockchain/wintr/time" ) @@ -14,11 +21,28 @@ const ( ) type ( + UserID = users.UserID + UserProfile = users.UserProfile + + Repository interface { + io.Closer + + StartQuizSession(ctx context.Context, userID UserID, lang string) (*Quiz, error) + + ContinueQuizSession(ctx context.Context, userID UserID, question uint, answer uint8) (*Quiz, error) + } + + UserReader interface { + GetUserByID(ctx context.Context, userID UserID) (*UserProfile, error) + } + Result string - Quiz struct { + + Quiz struct { Progress *Progress `json:"progress,omitempty"` Result Result `json:"result,omitempty"` } + Progress struct { ExpiresAt *time.Time `json:"expiresAt" example:"2022-01-03T16:20:52.156534Z"` NextQuestion *Question `json:"nextQuestion"` @@ -26,9 +50,57 @@ type ( CorrectAnswers uint8 `json:"correctAnswers" example:"16"` IncorrectAnswers uint8 `json:"incorrectAnswers" example:"2"` } + Question struct { - Text string `json:"text" example:"Какая температура на улице?"` - Options []string `json:"options" example:"+21,-2,+33,0"` - Number uint8 `json:"number" example:"1"` + Text string `json:"text" example:"Какая температура на улице?" db:"question"` + Options []string `json:"options" example:"+21,-2,+33,0" db:"options"` + Number uint `json:"number" example:"1"` + ID uint `json:"-" db:"id"` + } +) + +var ( + ErrUnknownLanguage = newError("unknown language") + ErrInvalidKYCState = newError("invalid KYC state") + ErrUnknownUser = newError("unknown user") + ErrSessionIsAlreadyRunning = newError("another session is already running") + ErrSessionFinished = newError("session closed") + ErrSessionFinishedWithError = newError("session closed with error") + ErrSessionExpired = newError("session expired") + ErrUnknownQuestionNumber = newError("unknown question number") + ErrUnknownSession = newError("unknown session and/or user") +) + +const ( + applicationYamlKey = "kyc/quiz" +) + +var ( //nolint:gofumpt //. + //go:embed DDL.sql + ddl string +) + +type ( + quizError struct { + Msg string + } + userProgress struct { + StartedAt stdlibtime.Time `db:"started_at"` + Lang string `db:"language"` + Questions []uint `db:"questions"` + Answers []uint `db:"answers"` + CorrectAnswers []uint `db:"correct_answers"` + } + repositoryImpl struct { + DB *storage.DB + Shutdown func() error + Users UserReader + config + } + config struct { + MaxSessionDurationSeconds int `yaml:"maxSessionDurationSeconds"` + MaxQuestionsPerSession int `yaml:"maxQuestionsPerSession"` + MaxWrongAnswersPerSession int `yaml:"maxWrongAnswersPerSession"` + SessionCoolDownSeconds int `yaml:"sessionCoolDownSeconds"` } ) diff --git a/kyc/quiz/quiz.go b/kyc/quiz/quiz.go index d5147d03..d03b32c3 100644 --- a/kyc/quiz/quiz.go +++ b/kyc/quiz/quiz.go @@ -1,3 +1,507 @@ // SPDX-License-Identifier: ice License 1.0 package quiz + +import ( + "context" + "fmt" + "strconv" + stdlibtime "time" + + "github.com/hashicorp/go-multierror" + "github.com/pkg/errors" + + "github.com/ice-blockchain/eskimo/users" + appcfg "github.com/ice-blockchain/wintr/config" + "github.com/ice-blockchain/wintr/connectors/storage/v2" + "github.com/ice-blockchain/wintr/time" +) + +func mustLoadConfig() config { + var cfg config + + appcfg.MustLoadFromKey(applicationYamlKey, &cfg) + + if cfg.MaxSessionDurationSeconds == 0 { + panic("max_session_duration_seconds is not set") + } + + if cfg.MaxQuestionsPerSession == 0 { + panic("max_questions_per_session is not set") + } + + if cfg.SessionCoolDownSeconds == 0 { + panic("session_cool_down_seconds is not set") + } + + return cfg +} + +func (e *quizError) Error() string { + return e.Msg +} + +func newError(msg string) error { + return &quizError{Msg: msg} +} + +func NewRepository(ctx context.Context, userReader UserReader) Repository { + return newRepositoryImpl(ctx, userReader) +} + +func newRepositoryImpl(ctx context.Context, userReader UserReader) *repositoryImpl { + db := storage.MustConnect(ctx, ddl, applicationYamlKey) + + return &repositoryImpl{ + DB: db, + Shutdown: db.Close, + Users: userReader, + config: mustLoadConfig(), + } +} + +func (r *repositoryImpl) Close() (err error) { + if r.Shutdown != nil { + err = r.Shutdown() + } + + return +} + +func (r *repositoryImpl) CheckUserKYC(ctx context.Context, userID UserID) error { + profile, err := r.Users.GetUserByID(ctx, userID) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return errors.Wrap(ErrUnknownUser, userID) + } + + return errors.Wrapf(err, "failed to get user by id: %v", userID) + } + + if profile.KYCStepPassed == nil || *profile.KYCStepPassed != (users.QuizKYCStep-1) { + state := "not set" + if profile.KYCStepPassed != nil { + state = strconv.Itoa(int(*profile.KYCStepPassed)) + } + + return errors.Wrap(ErrInvalidKYCState, state) + } + + return nil +} + +func (r *repositoryImpl) CheckUserFailedSession(ctx context.Context, userID UserID, now stdlibtime.Time, tx storage.QueryExecer) error { + type failedSession struct { + EndedAt stdlibtime.Time `db:"ended_at"` + } + + const stmt = ` +select max(ended_at) as ended_at from failed_quizz_sessions where user_id = $1 having max(ended_at) > $2 + ` + + term := now. + Add(stdlibtime.Duration(-r.config.SessionCoolDownSeconds) * stdlibtime.Second). + Truncate(stdlibtime.Second) + data, err := storage.Get[failedSession](ctx, tx, stmt, userID, term) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return nil + } + + return errors.Wrap(err, "failed to get failed session data") + } + + next := data.EndedAt. + Add(stdlibtime.Duration(r.config.SessionCoolDownSeconds) * stdlibtime.Second). + Truncate(stdlibtime.Second). + UTC() + + return errors.Wrapf(ErrSessionFinishedWithError, "wait until %v", next) +} + +func (r *repositoryImpl) CheckUserActiveSession(ctx context.Context, userID UserID, now stdlibtime.Time, tx storage.QueryExecer) error { + type userSession struct { + StartedAt stdlibtime.Time `db:"started_at"` + Finished bool `db:"finished"` + FinishedSuccfully bool `db:"ended_successfully"` + } + const stmt = `select started_at, ended_at is not null as finished, ended_successfully from quizz_sessions where user_id = $1` + + data, err := storage.Get[userSession](ctx, tx, stmt, userID) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return nil + } + + return errors.Wrap(err, "failed to get active session data") + } + + if data.Finished { + if data.FinishedSuccfully { + return ErrSessionFinished + } + + return ErrSessionFinishedWithError + } + + deadline := data.StartedAt.Add(stdlibtime.Duration(r.config.MaxSessionDurationSeconds) * stdlibtime.Second) + if deadline.After(now) { + return ErrSessionIsAlreadyRunning + } + + return nil +} + +func (r *repositoryImpl) SelectQuestions(ctx context.Context, tx storage.QueryExecer, lang string) ([]*Question, error) { + const stmt = ` +select id, options, question from questions where "language" = $1 order by random() limit $2 + ` + + questions, err := storage.Select[Question](ctx, tx, stmt, lang, r.config.MaxQuestionsPerSession) + if err != nil { + return nil, errors.Wrap(err, "failed to select questions") + } else if len(questions) == 0 { + return nil, errors.Wrap(ErrUnknownLanguage, lang) + } + + if len(questions) < r.config.MaxQuestionsPerSession { + panic(fmt.Sprintf("not enough questions for language %v: wanted %d but has only %v", + lang, r.config.MaxQuestionsPerSession, len(questions))) + } + + for i := range questions { + questions[i].Number = uint(i + 1) + } + + return questions, nil +} + +func questionsToSlice(questions []*Question) []uint { + result := make([]uint, 0, len(questions)) + for i := range questions { + result = append(result, questions[i].ID) + } + + return result +} + +func (*repositoryImpl) CreateSessionEntry( //nolint:revive //. + ctx context.Context, + userID UserID, + lang string, + questions []*Question, + now stdlibtime.Time, + tx storage.QueryExecer, +) error { + const stmt = ` +insert into quizz_sessions (user_id, language, questions, started_at, answers) values ($1, $2, $3, $4, '{}'::smallint[]) + on conflict on constraint quizz_sessions_pkey do update + set + started_at = excluded.started_at, + questions = excluded.questions, + answers = excluded.answers, + language = excluded.language, + ended_successfully = false + ` + + _, err := storage.Exec(ctx, tx, stmt, userID, lang, questionsToSlice(questions), now) + if err != nil { + if errors.Is(err, storage.ErrRelationNotFound) { + err = ErrUnknownUser + } + } + + return errors.Wrap(err, "failed to create session entry") +} + +func wrapErrorInTx(err error) error { + if err == nil { + return nil + } + + var quizErr *quizError + if errors.As(err, &quizErr) { + // Wa want to stop/abort the transaction in case of logic/flow error. + return multierror.Append(storage.ErrCheckFailed, err) + } + + return err +} + +func (r *repositoryImpl) StartQuizSession(ctx context.Context, userID UserID, lang string) (quiz *Quiz, err error) { //nolint:funlen //. + fnCheck := []func(context.Context, UserID, stdlibtime.Time, storage.QueryExecer) error{ + r.CheckUserFailedSession, + r.CheckUserActiveSession, + } + + err = r.CheckUserKYC(ctx, userID) + if err != nil { + return nil, err + } + + err = storage.DoInTransaction(ctx, r.DB, func(tx storage.QueryExecer) error { + now := stdlibtime.Now().Truncate(stdlibtime.Second).UTC() + for _, fn := range fnCheck { + if err = fn(ctx, userID, now, tx); err != nil { + return wrapErrorInTx(err) + } + } + + questions, qErr := r.SelectQuestions(ctx, tx, lang) + if qErr != nil { + return wrapErrorInTx(qErr) + } + + err = r.CreateSessionEntry(ctx, userID, lang, questions, now, tx) + if err != nil { + return wrapErrorInTx(err) + } + + quiz = &Quiz{ + Progress: &Progress{ + ExpiresAt: time.New(now.Add(stdlibtime.Duration(r.config.MaxSessionDurationSeconds) * stdlibtime.Second)), + NextQuestion: questions[0], + MaxQuestions: uint8(len(questions)), + }, + } + + return nil + }) + + return quiz, err +} + +func calculateProgress(correctAnswers, currentAnswers []uint) (correctNum, incorrectNum uint8) { + correct := correctAnswers + if len(currentAnswers) < len(correctAnswers) { + correct = correctAnswers[:len(currentAnswers)] + } + + for i := range correct { + if correct[i] == currentAnswers[i] { + correctNum++ + } else { + incorrectNum++ + } + } + + return +} + +func (r *repositoryImpl) CheckUserRunningSession( //nolint:funlen //. + ctx context.Context, + userID UserID, + now stdlibtime.Time, + tx storage.QueryExecer, +) (userProgress, error) { + type userSession struct { + userProgress + Finished bool `db:"finished"` + FinishedSuccfully bool `db:"ended_successfully"` + } + const stmt = ` +select + started_at, + ended_at is not null as finished, + questions, + session.language, + answers, + array_agg(questions.correct_option order by q.nr) as correct_answers, + ended_successfully +from + quizz_sessions session, + questions + inner join unnest(session.questions) with ordinality AS q(id, nr) + on questions.id = q.id +where + user_id = $1 and + questions."language" = session.language +group by + started_at, + ended_at, + questions, + session.language, + answers, + ended_successfully +` + + data, err := storage.Get[userSession](ctx, tx, stmt, userID) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return userProgress{}, ErrUnknownSession + } + + return userProgress{}, errors.Wrap(err, "failed to get running session data") + } + + if data.Finished { + if data.FinishedSuccfully { + return userProgress{}, ErrSessionFinished + } + + return userProgress{}, ErrSessionFinishedWithError + } + + deadline := data.StartedAt.Add(stdlibtime.Duration(r.config.MaxSessionDurationSeconds) * stdlibtime.Second) + if deadline.Before(now) { + return userProgress{}, ErrSessionExpired + } + + return data.userProgress, nil +} + +func (*repositoryImpl) CheckQuestionNumber(ctx context.Context, questions []uint, num uint, tx storage.QueryExecer) (uint, error) { + type currentQuestion struct { + CorrectOption uint8 `db:"correct_option"` + } + + if num == 0 || num > uint(len(questions)) { + return 0, ErrUnknownQuestionNumber + } + + data, err := storage.Get[currentQuestion](ctx, tx, `select correct_option from questions where id = $1`, questions[num-1]) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return 0, ErrUnknownQuestionNumber + } + + return 0, errors.Wrap(err, "failed to get current question data") + } + + return uint(data.CorrectOption), nil +} + +func (*repositoryImpl) UserAddAnswer(ctx context.Context, userID UserID, tx storage.QueryExecer, answer uint8) ([]uint, error) { + const stmt = ` +update quizz_sessions +set + answers = array_append(answers, $2) +where + user_id = $1 +returning answers + ` + + data, err := storage.Get[userProgress](ctx, tx, stmt, userID, answer) + if err != nil { + if errors.Is(err, storage.ErrNotFound) { + return nil, ErrUnknownSession + } + + return nil, errors.Wrap(err, "failed to update session") + } + + return data.Answers, nil +} + +func (*repositoryImpl) LoadQuestionByID(ctx context.Context, tx storage.QueryExecer, lang string, questionID uint) (*Question, error) { + const stmt = ` +select id, options, question from questions where "language" = $1 and id = $2 + ` + + question, err := storage.Get[Question](ctx, tx, stmt, lang, questionID) + if err != nil { + return nil, errors.Wrap(err, "failed to select questions") + } + + return question, nil +} + +func (*repositoryImpl) UserMarkSessionAsFinished(ctx context.Context, userID UserID, now stdlibtime.Time, tx storage.QueryExecer, successful bool) error { + const stmt = ` +with result as ( + update quizz_sessions + set + ended_at = $3, + ended_successfully = $2 + where + user_id = $1 + returning * +) +insert into failed_quizz_sessions (started_at, ended_at, questions, answers, language, user_id) +select + result.started_at, + result.ended_at, + result.questions, + result.answers, + result.language, + result.user_id +from result +where + result.ended_successfully = false + ` + + if _, err := storage.Exec(ctx, tx, stmt, userID, successful, now); err != nil { + return errors.Wrap(err, "failed to mark session as finished") + } + + return nil +} + +func (r *repositoryImpl) ContinueQuizSession( //nolint:funlen,revive //. + ctx context.Context, + userID UserID, + question uint, + answer uint8, +) (quiz *Quiz, err error) { + err = storage.DoInTransaction(ctx, r.DB, func(tx storage.QueryExecer) error { + now := stdlibtime.Now().Truncate(stdlibtime.Second).UTC() + progress, pErr := r.CheckUserRunningSession(ctx, userID, now, tx) + if pErr != nil { + return wrapErrorInTx(pErr) + } + _, err = r.CheckQuestionNumber(ctx, progress.Questions, question, tx) + if err != nil { + return wrapErrorInTx(err) + } else if uint(len(progress.Answers)) != question-1 { + return wrapErrorInTx(errors.Wrap(ErrUnknownQuestionNumber, "please answer questions in order")) + } + newAnswers, aErr := r.UserAddAnswer(ctx, userID, tx, answer) + if aErr != nil { + return wrapErrorInTx(aErr) + } + correctNum, incorrectNum := calculateProgress(progress.CorrectAnswers, newAnswers) + quiz = &Quiz{ + Progress: &Progress{ + MaxQuestions: uint8(len(progress.Questions)), + CorrectAnswers: correctNum, + IncorrectAnswers: incorrectNum, + }, + } + + if len(newAnswers) != len(progress.CorrectAnswers) { + nextQuestion, nErr := r.LoadQuestionByID(ctx, tx, progress.Lang, progress.Questions[question]) + if nErr != nil { + return wrapErrorInTx(nErr) + } + nextQuestion.Number = question + 1 + quiz.Progress.ExpiresAt = time.New(now.Add(stdlibtime.Duration(r.config.MaxSessionDurationSeconds) * stdlibtime.Second)) + quiz.Progress.NextQuestion = nextQuestion + + return nil + } + + if int(incorrectNum) > r.config.MaxWrongAnswersPerSession { + quiz.Result = FailureResult + err = r.UserMarkSessionAsFinished(ctx, userID, now, tx, false) + } else { + quiz.Result = SuccessResult + err = r.UserMarkSessionAsFinished(ctx, userID, now, tx, true) + } + + return wrapErrorInTx(err) + }) + + return quiz, err +} + +func (r *repositoryImpl) ResetQuizSession(ctx context.Context, userID UserID) error { + // $1: user_id. + const stmt = ` + delete from quizz_sessions + where + user_id = $1 + ` + _, err := storage.Exec(ctx, r.DB, stmt, userID) + + return errors.Wrap(err, "failed to reset session") +} diff --git a/kyc/quiz/quiz_test.go b/kyc/quiz/quiz_test.go new file mode 100644 index 00000000..46ca0d41 --- /dev/null +++ b/kyc/quiz/quiz_test.go @@ -0,0 +1,363 @@ +// SPDX-License-Identifier: ice License 1.0 + +package quiz + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ice-blockchain/eskimo/users" + "github.com/ice-blockchain/wintr/connectors/storage/v2" +) + +func helperInsertQuestion(t *testing.T, r *repositoryImpl) { + t.Helper() + + const stmt = ` +insert into questions (correct_option, options, language, question) values + (1, '{"Paris", "Melbourne", "Warsaw", "Guadalajara"}', 'en', 'What is the capital of France?'), + (2, '{"Kyiv", "Madrid", "Milan", "Schaarzen"}', 'en', 'What is the capital of Spain?'), + (3, '{"Waalkerk", "İstanbul", "Berlin", "Wien"}', 'en', 'What is the capital of Germany?') +on conflict do nothing; + ` + + _, err := storage.Exec(context.Background(), r.DB, stmt) + require.NoError(t, err) +} + +func helperSolveQuestion(t *testing.T, text string) uint8 { + t.Helper() + + switch text { + case "What is the capital of France?": + return 1 + case "What is the capital of Spain?": + return 2 + case "What is the capital of Germany?": + return 3 + default: + t.Errorf("unknown question: %s", text) + } + + return 0 +} + +func helperForceFinishSession(t *testing.T, r *repositoryImpl, userID UserID, result bool) { + t.Helper() + + _, err := storage.Exec(context.TODO(), r.DB, "update quizz_sessions set ended_at = now(), ended_successfully = $2 where user_id = $1", userID, result) + require.NoError(t, err) +} + +func helperForceResetSessionStartedAt(t *testing.T, r *repositoryImpl, userID UserID) { + t.Helper() + + _, err := storage.Exec(context.TODO(), r.DB, "update quizz_sessions set ended_at = NULL, started_at = to_timestamp(42) where user_id = $1", userID) + require.NoError(t, err) +} + +func helperSessionReset(t *testing.T, r *repositoryImpl, userID UserID, full bool) { + t.Helper() + + err := r.ResetQuizSession(context.Background(), userID) + require.NoError(t, err) + + if full { + _, err = storage.Exec(context.TODO(), r.DB, "delete from failed_quizz_sessions where user_id = $1", userID) + require.NoError(t, err) + } +} + +type mockUserReader struct{} + +func (*mockUserReader) GetUserByID(ctx context.Context, userID UserID) (*UserProfile, error) { + profile := &UserProfile{ + User: &users.User{}, + } + + switch userID { + case "bogus": + s := users.Social1KYCStep + profile.KYCStepPassed = &s + + case "invalid_kyc": + s := users.LivenessDetectionKYCStep + profile.KYCStepPassed = &s + + case "storage_error": + return nil, storage.ErrCheckFailed + + case "unknown_user": + return nil, storage.ErrNotFound + } + + return profile, nil +} + +func testManagerSessionStart(ctx context.Context, t *testing.T, r *repositoryImpl) { + helperSessionReset(t, r, "bogus", true) + + t.Run("UnknownUser", func(t *testing.T) { + _, err := r.StartQuizSession(ctx, "unknown_user", "en") + require.ErrorIs(t, err, ErrUnknownUser) + }) + + t.Run("UnknownLanguage", func(t *testing.T) { + _, err := r.StartQuizSession(ctx, "bogus", "ff") + require.ErrorIs(t, err, ErrUnknownLanguage) + }) + + t.Run("InvalidKYCState", func(t *testing.T) { + _, err := r.StartQuizSession(ctx, "invalid_kyc", "en") + require.ErrorIs(t, err, ErrInvalidKYCState) + }) + + t.Run("StorageError", func(t *testing.T) { + _, err := r.StartQuizSession(ctx, "storage_error", "en") + require.ErrorIs(t, err, storage.ErrCheckFailed) + }) + + t.Run("Sessions", func(t *testing.T) { + t.Run("OK", func(t *testing.T) { + session, err := r.StartQuizSession(ctx, "bogus", "en") + require.NoError(t, err) + require.NotNil(t, session) + require.NotNil(t, session.Progress) + require.NotNil(t, session.Progress.ExpiresAt) + require.NotEmpty(t, session.Progress.NextQuestion) + require.Equal(t, uint8(3), session.Progress.MaxQuestions) + require.Equal(t, uint(1), session.Progress.NextQuestion.Number) + }) + t.Run("AlreadyExists", func(t *testing.T) { + _, err := r.StartQuizSession(ctx, "bogus", "en") + require.ErrorIs(t, err, ErrSessionIsAlreadyRunning) + }) + t.Run("Finished", func(t *testing.T) { + t.Run("Success", func(t *testing.T) { + helperForceFinishSession(t, r, "bogus", true) + _, err := r.StartQuizSession(ctx, "bogus", "en") + require.ErrorIs(t, err, ErrSessionFinished) + }) + t.Run("Error", func(t *testing.T) { + helperForceFinishSession(t, r, "bogus", false) + _, err := r.StartQuizSession(ctx, "bogus", "en") + require.ErrorIs(t, err, ErrSessionFinishedWithError) + }) + }) + t.Run("Expired", func(t *testing.T) { + helperForceResetSessionStartedAt(t, r, "bogus") + session, err := r.StartQuizSession(ctx, "bogus", "en") + require.NoError(t, err) + require.NotNil(t, session) + require.NotNil(t, session.Progress) + require.NotNil(t, session.Progress.ExpiresAt) + require.NotEmpty(t, session.Progress.NextQuestion) + require.Equal(t, uint8(3), session.Progress.MaxQuestions) + require.Equal(t, uint(1), session.Progress.NextQuestion.Number) + }) + t.Run("CoolDown", func(t *testing.T) { + helperSessionReset(t, r, "bogus", true) + session, err := r.StartQuizSession(ctx, "bogus", "en") + require.NoError(t, err) + + for i := uint(0); i < uint(session.Progress.MaxQuestions); i++ { + session, err = r.ContinueQuizSession(ctx, "bogus", i+uint(1), 0) + require.NoError(t, err) + require.NotNil(t, session) + } + require.Equal(t, FailureResult, session.Result) + + _, err = r.StartQuizSession(ctx, "bogus", "en") + require.ErrorIs(t, err, ErrSessionFinishedWithError) + }) + }) + +} + +func testManagerSessionContinueErrors(ctx context.Context, t *testing.T, r *repositoryImpl) { + helperSessionReset(t, r, "bogus", true) + + t.Run("UnknownSession", func(t *testing.T) { + _, err := r.ContinueQuizSession(ctx, "unknown_user", 1, 1) + require.ErrorIs(t, err, ErrUnknownSession) + }) + + t.Run("Finished", func(t *testing.T) { + t.Run("Success", func(t *testing.T) { + helperSessionReset(t, r, "bogus", true) + data, err := r.StartQuizSession(ctx, "bogus", "en") + require.NoError(t, err) + helperForceFinishSession(t, r, "bogus", true) + _, err = r.ContinueQuizSession(ctx, "bogus", data.Progress.NextQuestion.Number, 1) + require.ErrorIs(t, err, ErrSessionFinished) + }) + t.Run("Error", func(t *testing.T) { + helperSessionReset(t, r, "bogus", true) + data, err := r.StartQuizSession(ctx, "bogus", "en") + require.NoError(t, err) + helperForceFinishSession(t, r, "bogus", false) + _, err = r.ContinueQuizSession(ctx, "bogus", data.Progress.NextQuestion.Number, 1) + require.ErrorIs(t, err, ErrSessionFinishedWithError) + }) + helperSessionReset(t, r, "bogus", true) + }) + + t.Run("Expired", func(t *testing.T) { + helperSessionReset(t, r, "bogus", true) + data, err := r.StartQuizSession(ctx, "bogus", "en") + require.NoError(t, err) + helperForceResetSessionStartedAt(t, r, "bogus") + _, err = r.ContinueQuizSession(ctx, "bogus", data.Progress.NextQuestion.Number, 1) + require.ErrorIs(t, err, ErrSessionExpired) + }) + + t.Run("UnknownQuestionNumber", func(t *testing.T) { + helperSessionReset(t, r, "bogus", true) + _, err := r.StartQuizSession(ctx, "bogus", "en") + require.NoError(t, err) + for _, n := range []uint{0, 4, 5, 6, 7, 10, 20, 100} { + _, err = r.ContinueQuizSession(ctx, "bogus", n, 1) + require.ErrorIs(t, err, ErrUnknownQuestionNumber) + } + }) + + t.Run("AnswersOrder", func(t *testing.T) { + helperSessionReset(t, r, "bogus", true) + _, err := r.StartQuizSession(ctx, "bogus", "en") + require.NoError(t, err) + _, err = r.ContinueQuizSession(ctx, "bogus", 1, 1) + require.NoError(t, err) + // Skip 2nd question. + _, err = r.ContinueQuizSession(ctx, "bogus", 3, 1) + require.ErrorIs(t, err, ErrUnknownQuestionNumber) + }) +} + +func testManagerSessionContinueWithCorrectAnswers(ctx context.Context, t *testing.T, r *repositoryImpl) { + helperSessionReset(t, r, "bogus", true) + + session, err := r.StartQuizSession(ctx, "bogus", "en") + require.NoError(t, err) + require.NotNil(t, session) + require.NotNil(t, session.Progress) + require.NotNil(t, session.Progress.ExpiresAt) + require.NotEmpty(t, session.Progress.NextQuestion) + require.Equal(t, uint8(3), session.Progress.MaxQuestions) + require.NotEmpty(t, session.Progress.NextQuestion.Text) + + ans := helperSolveQuestion(t, session.Progress.NextQuestion.Text) + t.Logf("q: %v, ans: %d", session.Progress.NextQuestion.Text, ans) + session, err = r.ContinueQuizSession(ctx, "bogus", session.Progress.NextQuestion.Number, ans) + require.NoError(t, err) + require.NotNil(t, session) + require.Empty(t, session.Result) + require.NotNil(t, session.Progress) + require.NotNil(t, session.Progress.ExpiresAt) + require.Equal(t, uint8(3), session.Progress.MaxQuestions) + require.NotEmpty(t, session.Progress.NextQuestion.Text) + require.Equal(t, uint8(1), session.Progress.CorrectAnswers) + require.Equal(t, uint8(0), session.Progress.IncorrectAnswers) + + ans = helperSolveQuestion(t, session.Progress.NextQuestion.Text) + t.Logf("q: %v, ans: %d", session.Progress.NextQuestion.Text, ans) + session, err = r.ContinueQuizSession(ctx, "bogus", session.Progress.NextQuestion.Number, ans) + require.NoError(t, err) + require.NotNil(t, session) + require.Empty(t, session.Result) + require.NotNil(t, session.Progress) + require.NotNil(t, session.Progress.ExpiresAt) + require.Equal(t, uint8(3), session.Progress.MaxQuestions) + require.NotEmpty(t, session.Progress.NextQuestion.Text) + require.Equal(t, uint8(2), session.Progress.CorrectAnswers) + require.Equal(t, uint8(0), session.Progress.IncorrectAnswers) + + ans = helperSolveQuestion(t, session.Progress.NextQuestion.Text) + t.Logf("q: %v, ans: %d", session.Progress.NextQuestion.Text, ans) + session, err = r.ContinueQuizSession(ctx, "bogus", session.Progress.NextQuestion.Number, ans) + require.NoError(t, err) + require.NotNil(t, session) + require.Equal(t, SuccessResult, session.Result) + require.NotNil(t, session.Progress) + require.Nil(t, session.Progress.NextQuestion) + require.Equal(t, uint8(3), session.Progress.MaxQuestions) + require.Equal(t, uint8(3), session.Progress.CorrectAnswers) + require.Equal(t, uint8(0), session.Progress.IncorrectAnswers) +} + +func testManagerSessionContinueWithIncorrectAnswers(ctx context.Context, t *testing.T, r *repositoryImpl) { + helperSessionReset(t, r, "bogus", true) + + session, err := r.StartQuizSession(ctx, "bogus", "en") + require.NoError(t, err) + require.NotNil(t, session) + require.NotNil(t, session.Progress) + require.NotNil(t, session.Progress.ExpiresAt) + require.NotEmpty(t, session.Progress.NextQuestion) + require.Equal(t, uint8(3), session.Progress.MaxQuestions) + require.NotEmpty(t, session.Progress.NextQuestion.Text) + require.Equal(t, uint(1), session.Progress.NextQuestion.Number) + + session, err = r.ContinueQuizSession(ctx, "bogus", session.Progress.NextQuestion.Number, 0) + require.NoError(t, err) + require.NotNil(t, session) + require.Empty(t, session.Result) + require.NotNil(t, session.Progress) + require.NotNil(t, session.Progress.ExpiresAt) + require.Equal(t, uint8(3), session.Progress.MaxQuestions) + require.NotEmpty(t, session.Progress.NextQuestion.Text) + require.Equal(t, uint8(0), session.Progress.CorrectAnswers) + require.Equal(t, uint8(1), session.Progress.IncorrectAnswers) + require.Equal(t, uint(2), session.Progress.NextQuestion.Number) + + ans := helperSolveQuestion(t, session.Progress.NextQuestion.Text) + t.Logf("q: %v, ans: %d", session.Progress.NextQuestion.Text, ans) + session, err = r.ContinueQuizSession(ctx, "bogus", session.Progress.NextQuestion.Number, ans) + require.NoError(t, err) + require.NotNil(t, session) + require.Empty(t, session.Result) + require.NotNil(t, session.Progress) + require.NotNil(t, session.Progress.ExpiresAt) + require.Equal(t, uint8(3), session.Progress.MaxQuestions) + require.NotEmpty(t, session.Progress.NextQuestion.Text) + require.Equal(t, uint8(1), session.Progress.CorrectAnswers) + require.Equal(t, uint8(1), session.Progress.IncorrectAnswers) + require.Equal(t, uint(3), session.Progress.NextQuestion.Number) + + session, err = r.ContinueQuizSession(ctx, "bogus", session.Progress.NextQuestion.Number, 0) + require.NoError(t, err) + require.NotNil(t, session) + require.Equal(t, FailureResult, session.Result) + require.NotNil(t, session.Progress) + require.Nil(t, session.Progress.NextQuestion) + require.Equal(t, uint8(3), session.Progress.MaxQuestions) + require.Equal(t, uint8(1), session.Progress.CorrectAnswers) + require.Equal(t, uint8(2), session.Progress.IncorrectAnswers) +} + +func TestSessionManager(t *testing.T) { + t.Parallel() + + ctx := context.TODO() + + // Create user repo because we need its schema. + usersRepo := users.New(ctx, nil) + require.NotNil(t, usersRepo) + + repo := newRepositoryImpl(ctx, new(mockUserReader)) + require.NotNil(t, repo) + + helperInsertQuestion(t, repo) + + t.Run("Start", func(t *testing.T) { + testManagerSessionStart(ctx, t, repo) + }) + t.Run("Continue", func(t *testing.T) { + testManagerSessionContinueErrors(ctx, t, repo) + testManagerSessionContinueWithCorrectAnswers(ctx, t, repo) + testManagerSessionContinueWithIncorrectAnswers(ctx, t, repo) + }) + + require.NoError(t, repo.Close()) +} From bb7903d795e32bcc9bfe3f06914cdbb32472a620 Mon Sep 17 00:00:00 2001 From: Dionysos <75300347+ice-dionysos@users.noreply.github.com> Date: Thu, 30 Nov 2023 13:55:41 +0100 Subject: [PATCH 2/4] kyc: quiz: add modify user --- kyc/quiz/contract.go | 8 +++--- kyc/quiz/quiz.go | 59 +++++++++++++++++++++++++++++++++++++++---- kyc/quiz/quiz_test.go | 5 ++++ 3 files changed, 64 insertions(+), 8 deletions(-) diff --git a/kyc/quiz/contract.go b/kyc/quiz/contract.go index 26cb3c53..d3e034ce 100644 --- a/kyc/quiz/contract.go +++ b/kyc/quiz/contract.go @@ -6,6 +6,7 @@ import ( "context" _ "embed" "io" + "mime/multipart" stdlibtime "time" "github.com/ice-blockchain/eskimo/users" @@ -32,8 +33,9 @@ type ( ContinueQuizSession(ctx context.Context, userID UserID, question uint, answer uint8) (*Quiz, error) } - UserReader interface { - GetUserByID(ctx context.Context, userID UserID) (*UserProfile, error) + UserRepository interface { + GetUserByID(ctx context.Context, userID string) (*users.UserProfile, error) + ModifyUser(ctx context.Context, usr *users.User, profilePicture *multipart.FileHeader) error } Result string @@ -94,7 +96,7 @@ type ( repositoryImpl struct { DB *storage.DB Shutdown func() error - Users UserReader + Users UserRepository config } config struct { diff --git a/kyc/quiz/quiz.go b/kyc/quiz/quiz.go index d03b32c3..fbd9aeda 100644 --- a/kyc/quiz/quiz.go +++ b/kyc/quiz/quiz.go @@ -45,17 +45,17 @@ func newError(msg string) error { return &quizError{Msg: msg} } -func NewRepository(ctx context.Context, userReader UserReader) Repository { - return newRepositoryImpl(ctx, userReader) +func NewRepository(ctx context.Context, userRepo UserRepository) Repository { + return newRepositoryImpl(ctx, userRepo) } -func newRepositoryImpl(ctx context.Context, userReader UserReader) *repositoryImpl { +func newRepositoryImpl(ctx context.Context, userRepo UserRepository) *repositoryImpl { db := storage.MustConnect(ctx, ddl, applicationYamlKey) return &repositoryImpl{ DB: db, Shutdown: db.Close, - Users: userReader, + Users: userRepo, config: mustLoadConfig(), } } @@ -437,7 +437,53 @@ where return nil } -func (r *repositoryImpl) ContinueQuizSession( //nolint:funlen,revive //. +func (r *repositoryImpl) fetchUserProfileForModify(ctx context.Context, userID UserID) (*users.User, error) { + profile, err := r.Users.GetUserByID(ctx, userID) + if err != nil { + return nil, errors.Wrapf(err, "failed to get user by id: %v", userID) + } + + usr := new(users.User) + usr.ID = userID + usr.KYCStepsLastUpdatedAt = profile.KYCStepsLastUpdatedAt + usr.KYCStepsCreatedAt = profile.KYCStepsCreatedAt + + if usr.KYCStepsLastUpdatedAt == nil { + s := make([]*time.Time, 0, 1) + usr.KYCStepsLastUpdatedAt = &s + } + if usr.KYCStepsCreatedAt == nil { + s := make([]*time.Time, 0, 1) + usr.KYCStepsCreatedAt = &s + } + + return usr, nil +} + +func (r *repositoryImpl) modifyUser(ctx context.Context, now *time.Time, userID UserID) error { + usr, err := r.fetchUserProfileForModify(ctx, userID) + if err != nil { + return err + } + + step := users.QuizKYCStep + usr.KYCStepPassed = &step + + if len(*usr.KYCStepsLastUpdatedAt) < int(step) { + *usr.KYCStepsLastUpdatedAt = append(*usr.KYCStepsLastUpdatedAt, now) + } else { + (*usr.KYCStepsLastUpdatedAt)[int(step)-1] = now + } + if len(*usr.KYCStepsCreatedAt) < int(step) { + *usr.KYCStepsCreatedAt = append(*usr.KYCStepsCreatedAt, now) + } else { + (*usr.KYCStepsCreatedAt)[int(step)-1] = now + } + + return errors.Wrapf(r.Users.ModifyUser(ctx, usr, nil), "failed to modify user %#v", usr) +} + +func (r *repositoryImpl) ContinueQuizSession( //nolint:funlen,revive,gocognit //. ctx context.Context, userID UserID, question uint, @@ -486,6 +532,9 @@ func (r *repositoryImpl) ContinueQuizSession( //nolint:funlen,revive //. } else { quiz.Result = SuccessResult err = r.UserMarkSessionAsFinished(ctx, userID, now, tx, true) + if err == nil { + err = r.modifyUser(ctx, time.New(now), userID) + } } return wrapErrorInTx(err) diff --git a/kyc/quiz/quiz_test.go b/kyc/quiz/quiz_test.go index 46ca0d41..bfcada48 100644 --- a/kyc/quiz/quiz_test.go +++ b/kyc/quiz/quiz_test.go @@ -4,6 +4,7 @@ package quiz import ( "context" + "mime/multipart" "testing" "github.com/stretchr/testify/require" @@ -96,6 +97,10 @@ func (*mockUserReader) GetUserByID(ctx context.Context, userID UserID) (*UserPro return profile, nil } +func (*mockUserReader) ModifyUser(ctx context.Context, usr *users.User, profilePicture *multipart.FileHeader) error { + return nil +} + func testManagerSessionStart(ctx context.Context, t *testing.T, r *repositoryImpl) { helperSessionReset(t, r, "bogus", true) From a0444ded189483e1cd13147516a68a28d0f4527d Mon Sep 17 00:00:00 2001 From: Ares <75481906+ice-ares@users.noreply.github.com> Date: Thu, 11 Jan 2024 19:14:56 +0200 Subject: [PATCH 3/4] added api to skip a quiz session --- cmd/eskimo-hut/contract.go | 13 ++-- cmd/eskimo-hut/eskimo_hut.go | 4 +- cmd/eskimo-hut/kyc.go | 44 ++++++------- go.mod | 16 ++--- go.sum | 32 +++++----- kyc/quiz/contract.go | 12 ++-- kyc/quiz/quiz.go | 120 +++++++++++++++++++++-------------- 7 files changed, 132 insertions(+), 109 deletions(-) diff --git a/cmd/eskimo-hut/contract.go b/cmd/eskimo-hut/contract.go index 96341237..95d5b200 100644 --- a/cmd/eskimo-hut/contract.go +++ b/cmd/eskimo-hut/contract.go @@ -143,9 +143,9 @@ type ( Authorization string `header:"Authorization" swaggerignore:"true" required:"true" allowForbiddenWriteOperation:"true" allowUnauthorized:"true"` } StartOrContinueKYCStep4SessionRequestBody struct { + QuestionNumber *uint8 `form:"questionNumber" required:"true" swaggerignore:"true" example:"11"` + SelectedOption *uint8 `form:"selectedOption" required:"true" swaggerignore:"true" example:"0"` Language string `form:"language" required:"true" swaggerignore:"true" example:"en"` - QuestionNumber uint `form:"questionNumber" required:"true" swaggerignore:"true" example:"11"` - SelectedOption uint8 `form:"selectedOption" required:"true" swaggerignore:"true" example:"0"` } TryResetKYCStepsRequestBody struct { Authorization string `header:"Authorization" swaggerignore:"true" required:"true" example:"some token"` @@ -189,11 +189,8 @@ const ( noPendingLoginSessionErrorCode = "NO_PENDING_LOGIN_SESSION" - quizAlreadyCompletedSuccessfullyErrorCode = "QUIZ_ALREADY_COMPLETED_SUCCESSFULLY" - quizNotAvailableErrorCode = "QUIZ_NOT_AVAILABLE" - quizAlreadyRunningErrorCode = "QUIZ_ALREADY_RUNNING" - quizUnknownQuestionNumErrorCode = "QUIZ_UNKNOWN_QUESTION_NUM" - quizExpiredErrorCode = "QUIZ_EXPIRED" + quizAlreadyRunningErrorCode = "QUIZ_ALREADY_RUNNING" + quizUnknownQuestionNumErrorCode = "QUIZ_UNKNOWN_QUESTION_NUM" socialKYCStepAlreadyCompletedSuccessfullyErrorCode = "SOCIAL_KYC_STEP_ALREADY_COMPLETED_SUCCESSFULLY" socialKYCStepNotAvailableErrorCode = "SOCIAL_KYC_STEP_NOT_AVAILABLE" @@ -214,7 +211,7 @@ type ( // | service implements server.State and is responsible for managing the state and lifecycle of the package. service struct { usersProcessor users.Processor - kycquiz kycquiz.Repository + quizRepository kycquiz.Repository authEmailLinkClient emaillink.Client socialRepository kycsocial.Repository } diff --git a/cmd/eskimo-hut/eskimo_hut.go b/cmd/eskimo-hut/eskimo_hut.go index da6387b3..2556c380 100644 --- a/cmd/eskimo-hut/eskimo_hut.go +++ b/cmd/eskimo-hut/eskimo_hut.go @@ -50,9 +50,9 @@ func (s *service) RegisterRoutes(router *server.Router) { func (s *service) Init(ctx context.Context, cancel context.CancelFunc) { s.usersProcessor = users.StartProcessor(ctx, cancel) - s.kycquiz = kycquiz.NewRepository(ctx, s.usersProcessor) s.authEmailLinkClient = emaillink.NewClient(ctx, s.usersProcessor, server.Auth(ctx)) s.socialRepository = social.New(ctx, s.usersProcessor) + s.quizRepository = kycquiz.NewRepository(ctx, s.usersProcessor) } func (s *service) Close(ctx context.Context) error { @@ -61,10 +61,10 @@ func (s *service) Close(ctx context.Context) error { } return multierror.Append( //nolint:wrapcheck // Not needed. + errors.Wrap(s.quizRepository.Close(), "could not close quiz repository"), errors.Wrap(s.socialRepository.Close(), "could not close socialRepository"), errors.Wrap(s.authEmailLinkClient.Close(), "could not close authEmailLinkClient"), errors.Wrap(s.usersProcessor.Close(), "could not close usersProcessor"), - errors.Wrap(s.kycquiz.Close(), "could not close kyc.quiz"), ).ErrorOrNil() } diff --git a/cmd/eskimo-hut/kyc.go b/cmd/eskimo-hut/kyc.go index 189200f1..849495bb 100644 --- a/cmd/eskimo-hut/kyc.go +++ b/cmd/eskimo-hut/kyc.go @@ -47,7 +47,7 @@ func (s *service) setupKYCRoutes(router *server.Router) { // @Failure 500 {object} server.ErrorResponse // @Failure 504 {object} server.ErrorResponse "if request times out" // @Router /kyc/startOrContinueKYCStep4Session/users/{userId} [POST]. -func (s *service) StartOrContinueKYCStep4Session( //nolint:gocritic,funlen,revive,gocognit,gocyclo,cyclop // . +func (s *service) StartOrContinueKYCStep4Session( //nolint:gocritic,funlen,revive,gocognit,cyclop // . ctx context.Context, req *server.Request[StartOrContinueKYCStep4SessionRequestBody, kycquiz.Quiz], ) (*server.Response[kycquiz.Quiz], *server.Response[server.ErrorResponse]) { @@ -57,15 +57,15 @@ func (s *service) StartOrContinueKYCStep4Session( //nolint:gocritic,funlen,reviv ) // Handle the session start. - if req.Data.QuestionNumber == magicNumberQuizStart && req.Data.SelectedOption == magicNumberQuizStart { + if *req.Data.QuestionNumber == magicNumberQuizStart && *req.Data.SelectedOption == magicNumberQuizStart { var ( err error quiz *kycquiz.Quiz ) langLoop: for _, lang := range []string{req.Data.Language, defaultLanguage} { - quiz, err = s.kycquiz.StartQuizSession(ctx, req.AuthenticatedUser.UserID, lang) - if err != nil { + if quiz, err = s.quizRepository.StartQuizSession(ctx, req.AuthenticatedUser.UserID, lang); err != nil { + err = errors.Wrapf(err, "failed to StartQuizSession for userID:%v,language:%v", req.AuthenticatedUser.UserID, lang) switch { case errors.Is(err, kycquiz.ErrUnknownLanguage): continue langLoop @@ -76,13 +76,8 @@ func (s *service) StartOrContinueKYCStep4Session( //nolint:gocritic,funlen,reviv case errors.Is(err, kycquiz.ErrSessionIsAlreadyRunning): return nil, server.ForbiddenWithCode(errors.Errorf("another quiz session is already running"), quizAlreadyRunningErrorCode) - case errors.Is(err, kycquiz.ErrSessionFinished): - return nil, server.Conflict( - errors.Errorf("quiz session already finished successfully, ignore it and proceed with mining"), - quizAlreadyCompletedSuccessfullyErrorCode) - - case errors.Is(err, kycquiz.ErrSessionFinishedWithError): - return nil, server.Conflict(errors.Errorf("quiz session already finished with error"), quizNotAvailableErrorCode) + case errors.Is(err, kycquiz.ErrSessionFinished), errors.Is(err, kycquiz.ErrSessionFinishedWithError), errors.Is(err, kycquiz.ErrInvalidKYCState): //nolint:lll // . + return nil, server.BadRequest(err, raceConditionErrorCode) default: return nil, server.Unexpected(err) @@ -94,26 +89,19 @@ func (s *service) StartOrContinueKYCStep4Session( //nolint:gocritic,funlen,reviv } // Handle the session continuation. - session, err := s.kycquiz.ContinueQuizSession(ctx, req.AuthenticatedUser.UserID, req.Data.QuestionNumber, req.Data.SelectedOption) + session, err := s.quizRepository.ContinueQuizSession(ctx, req.AuthenticatedUser.UserID, *req.Data.QuestionNumber, *req.Data.SelectedOption) if err != nil { + err = errors.Wrapf(err, "failed to ContinueQuizSession for userID:%v,question:%v,option:%v", req.AuthenticatedUser.UserID, *req.Data.QuestionNumber, *req.Data.SelectedOption) //nolint:lll // . switch { case errors.Is(err, kycquiz.ErrUnknownUser) || errors.Is(err, kycquiz.ErrUnknownSession): return nil, server.NotFound(err, userNotFoundErrorCode) - case errors.Is(err, kycquiz.ErrSessionFinished): - return nil, server.Conflict( - errors.Errorf("quiz session already finished successfully, ignore it and proceed with mining"), - quizAlreadyCompletedSuccessfullyErrorCode) - - case errors.Is(err, kycquiz.ErrSessionFinishedWithError): - return nil, server.Conflict(errors.Errorf("quiz session already finished with error"), quizNotAvailableErrorCode) + case errors.Is(err, kycquiz.ErrSessionExpired), errors.Is(err, kycquiz.ErrSessionFinished), errors.Is(err, kycquiz.ErrSessionFinishedWithError), errors.Is(err, kycquiz.ErrInvalidKYCState): //nolint:lll // . + return nil, server.BadRequest(err, raceConditionErrorCode) case errors.Is(err, kycquiz.ErrUnknownQuestionNumber): return nil, server.BadRequest(err, quizUnknownQuestionNumErrorCode) - case errors.Is(err, kycquiz.ErrSessionExpired): - return nil, server.Conflict(errors.Errorf("this quiz session has expired, please start a new one"), quizExpiredErrorCode) - default: return nil, server.Unexpected(err) } @@ -220,7 +208,7 @@ func validateVerifySocialKYCStep(req *server.Request[kycsocial.VerificationMetad // @Failure 500 {object} server.ErrorResponse // @Failure 504 {object} server.ErrorResponse "if request times out" // @Router /kyc/tryResetKYCSteps/users/{userId} [POST]. -func (s *service) TryResetKYCSteps( //nolint:gocritic,funlen,gocognit,revive // . +func (s *service) TryResetKYCSteps( //nolint:gocritic,funlen,gocognit,revive,cyclop,gocyclo // . ctx context.Context, req *server.Request[TryResetKYCStepsRequestBody, User], ) (*server.Response[User], *server.Response[server.ErrorResponse]) { @@ -242,6 +230,16 @@ func (s *service) TryResetKYCSteps( //nolint:gocritic,funlen,gocognit,revive // return nil, server.Unexpected(errors.Wrapf(err, "failed to skip kycStep %v", kycStep)) } } + case users.QuizKYCStep: + if err := s.quizRepository.SkipQuizSession(ctx, req.Data.UserID); err != nil { + if errors.Is(err, kycquiz.ErrInvalidKYCState) || errors.Is(err, kycquiz.ErrSessionFinished) || errors.Is(err, kycquiz.ErrSessionFinishedWithError) || errors.Is(err, kycquiz.ErrSessionExpired) { //nolint:lll // . + log.Error(errors.Wrapf(err, "skipQuizSession failed unexpectedly during tryResetKYCSteps for userID:%v", req.Data.UserID)) + err = nil + } + if err != nil { + return nil, server.Unexpected(errors.Wrapf(err, "failed to SkipQuizSession for userID:%v", req.Data.UserID)) + } + } } } resp, err := s.usersProcessor.TryResetKYCSteps(ctx, req.Data.UserID) diff --git a/go.mod b/go.mod index 2780fb10..a432e0e3 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ require ( github.com/ice-blockchain/go-tarantool-client v0.0.0-20230327200757-4fc71fa3f7bb github.com/ice-blockchain/wintr v1.128.0 github.com/imroc/req/v3 v3.42.3 - github.com/ip2location/ip2location-go/v9 v9.6.1 + github.com/ip2location/ip2location-go/v9 v9.7.0 github.com/jackc/pgx/v5 v5.5.1 github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.8.4 @@ -62,7 +62,7 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/jsonpointer v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.4 // indirect - github.com/go-openapi/spec v0.20.13 // indirect + github.com/go-openapi/spec v0.20.14 // indirect github.com/go-openapi/swag v0.22.7 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect @@ -104,7 +104,7 @@ require ( github.com/opencontainers/image-spec v1.1.0-rc5 // indirect github.com/opencontainers/runc v1.1.11 // indirect github.com/pelletier/go-toml/v2 v2.1.1 // indirect - github.com/pierrec/lz4/v4 v4.1.19 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/quic-go/qpack v0.4.0 // indirect github.com/quic-go/qtls-go1-20 v0.4.1 // indirect @@ -141,8 +141,8 @@ require ( go.uber.org/multierr v1.11.0 // indirect golang.org/x/arch v0.7.0 // indirect golang.org/x/crypto v0.18.0 // indirect - golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc // indirect - golang.org/x/oauth2 v0.15.0 // indirect + golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e // indirect + golang.org/x/oauth2 v0.16.0 // indirect golang.org/x/sync v0.6.0 // indirect golang.org/x/sys v0.16.0 // indirect golang.org/x/text v0.14.0 // indirect @@ -152,9 +152,9 @@ require ( google.golang.org/api v0.155.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/appengine/v2 v2.0.5 // indirect - google.golang.org/genproto v0.0.0-20240102182953-50ed04b92917 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 // indirect + google.golang.org/genproto v0.0.0-20240108191215-35c7eff3a6b1 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20240108191215-35c7eff3a6b1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240108191215-35c7eff3a6b1 // indirect google.golang.org/grpc v1.60.1 // indirect google.golang.org/protobuf v1.32.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect diff --git a/go.sum b/go.sum index 6233f41b..a3a9e8f0 100644 --- a/go.sum +++ b/go.sum @@ -115,8 +115,8 @@ github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbX github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= github.com/go-openapi/jsonreference v0.20.4 h1:bKlDxQxQJgwpUSgOENiMPzCTBVuc7vTdXSSgNeAhojU= github.com/go-openapi/jsonreference v0.20.4/go.mod h1:5pZJyJP2MnYCpoeoMAql78cCHauHj0V9Lhc506VOpw4= -github.com/go-openapi/spec v0.20.13 h1:XJDIN+dLH6vqXgafnl5SUIMnzaChQ6QTo0/UPMbkIaE= -github.com/go-openapi/spec v0.20.13/go.mod h1:8EOhTpBoFiask8rrgwbLC3zmJfz4zsCUueRuPM6GNkw= +github.com/go-openapi/spec v0.20.14 h1:7CBlRnw+mtjFGlPDRZmAMnq35cRzI91xj03HVyUi/Do= +github.com/go-openapi/spec v0.20.14/go.mod h1:8EOhTpBoFiask8rrgwbLC3zmJfz4zsCUueRuPM6GNkw= github.com/go-openapi/swag v0.22.7 h1:JWrc1uc/P9cSomxfnsFSVWoE1FW6bNbrVPmpQYpCcR8= github.com/go-openapi/swag v0.22.7/go.mod h1:Gl91UqO+btAM0plGGxHqJcQZ1ZTy6jbmridBTsDy8A0= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -201,8 +201,8 @@ github.com/ice-blockchain/wintr v1.128.0 h1:ABiIfL4AfpJ4kPGScnPzQW2SJV8JSoc4IQWh github.com/ice-blockchain/wintr v1.128.0/go.mod h1:Wq/uG3vDL/Na9lhpOo9NI7y3gLNZn2u/oa6+t85GT9c= github.com/imroc/req/v3 v3.42.3 h1:ryPG2AiwouutAopwPxKpWKyxgvO8fB3hts4JXlh3PaE= github.com/imroc/req/v3 v3.42.3/go.mod h1:Axz9Y/a2b++w5/Jht3IhQsdBzrG1ftJd1OJhu21bB2Q= -github.com/ip2location/ip2location-go/v9 v9.6.1 h1:4tYtSoRNpUwbbgf3NUmF7c5vV0QVCKEeRPSbMCXiQfE= -github.com/ip2location/ip2location-go/v9 v9.6.1/go.mod h1:MPLnsKxwQlvd2lBNcQCsLoyzJLDBFizuO67wXXdzoyI= +github.com/ip2location/ip2location-go/v9 v9.7.0 h1:ipwl67HOWcrw+6GOChkEXcreRQR37NabqBd2ayYa4Q0= +github.com/ip2location/ip2location-go/v9 v9.7.0/go.mod h1:MPLnsKxwQlvd2lBNcQCsLoyzJLDBFizuO67wXXdzoyI= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= @@ -271,8 +271,8 @@ github.com/opencontainers/runc v1.1.11 h1:9LjxyVlE0BPMRP2wuQDRlHV4941Jp9rc3F0+YK github.com/opencontainers/runc v1.1.11/go.mod h1:S+lQwSfncpBha7XTy/5lBwWgm5+y5Ma/O44Ekby9FK8= github.com/pelletier/go-toml/v2 v2.1.1 h1:LWAJwfNvjQZCFIDKWYQaM62NcYeYViCmWIwmOStowAI= github.com/pelletier/go-toml/v2 v2.1.1/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= -github.com/pierrec/lz4/v4 v4.1.19 h1:tYLzDnjDXh9qIxSTKHwXwOYmm9d887Y7Y1ZkyXYHAN4= -github.com/pierrec/lz4/v4 v4.1.19/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -384,8 +384,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= -golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc h1:ao2WRsKSzW6KuUY9IWPwWahcHCgR0s52IfwutMfEbdM= -golang.org/x/exp v0.0.0-20240103183307-be819d1f06fc/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= +golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e h1:723BNChdd0c2Wk6WOE320qGBiPtYx0F0Bbm1kriShfE= +golang.org/x/exp v0.0.0-20240110193028-0dcbfd608b1e/go.mod h1:iRJReGqOEeBhDZGkGbynYwcHlctCvnjTYIamk7uXpHI= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= @@ -413,8 +413,8 @@ golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ= -golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= +golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= +golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -486,12 +486,12 @@ google.golang.org/appengine/v2 v2.0.5/go.mod h1:WoEXGoXNfa0mLvaH5sV3ZSGXwVmy8yf7 google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20240102182953-50ed04b92917 h1:nz5NESFLZbJGPFxDT/HCn+V1mZ8JGNoY4nUpmW/Y2eg= -google.golang.org/genproto v0.0.0-20240102182953-50ed04b92917/go.mod h1:pZqR+glSb11aJ+JQcczCvgf47+duRuzNSKqE8YAQnV0= -google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917 h1:rcS6EyEaoCO52hQDupoSfrxI3R6C2Tq741is7X8OvnM= -google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917 h1:6G8oQ016D88m1xAKljMlBOOGWDZkes4kMhgGFlf8WcQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917/go.mod h1:xtjpI3tXFPP051KaWnhvxkiubL/6dJ18vLVf7q2pTOU= +google.golang.org/genproto v0.0.0-20240108191215-35c7eff3a6b1 h1:/IWabOtPziuXTEtI1KYCpM6Ss7vaAkeMxk+uXV/xvZs= +google.golang.org/genproto v0.0.0-20240108191215-35c7eff3a6b1/go.mod h1:+Rvu7ElI+aLzyDQhpHMFMMltsD6m7nqpuWDd2CwJw3k= +google.golang.org/genproto/googleapis/api v0.0.0-20240108191215-35c7eff3a6b1 h1:OPXtXn7fNMaXwO3JvOmF1QyTc00jsSFFz1vXXBOdCDo= +google.golang.org/genproto/googleapis/api v0.0.0-20240108191215-35c7eff3a6b1/go.mod h1:B5xPO//w8qmBDjGReYLpR6UJPnkldGkCSMoH/2vxJeg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240108191215-35c7eff3a6b1 h1:gphdwh0npgs8elJ4T6J+DQJHPVF7RsuJHCfwztUb4J4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240108191215-35c7eff3a6b1/go.mod h1:daQN87bsDqDoe316QbbvX60nMoJQa4r6Ds0ZuoAe5yA= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= diff --git a/kyc/quiz/contract.go b/kyc/quiz/contract.go index d3e034ce..a06f219c 100644 --- a/kyc/quiz/contract.go +++ b/kyc/quiz/contract.go @@ -30,7 +30,9 @@ type ( StartQuizSession(ctx context.Context, userID UserID, lang string) (*Quiz, error) - ContinueQuizSession(ctx context.Context, userID UserID, question uint, answer uint8) (*Quiz, error) + SkipQuizSession(ctx context.Context, userID UserID) error + + ContinueQuizSession(ctx context.Context, userID UserID, question, answer uint8) (*Quiz, error) } UserRepository interface { @@ -56,7 +58,7 @@ type ( Question struct { Text string `json:"text" example:"Какая температура на улице?" db:"question"` Options []string `json:"options" example:"+21,-2,+33,0" db:"options"` - Number uint `json:"number" example:"1"` + Number uint8 `json:"number" example:"1"` ID uint `json:"-" db:"id"` } ) @@ -89,9 +91,9 @@ type ( userProgress struct { StartedAt stdlibtime.Time `db:"started_at"` Lang string `db:"language"` - Questions []uint `db:"questions"` - Answers []uint `db:"answers"` - CorrectAnswers []uint `db:"correct_answers"` + Questions []uint8 `db:"questions"` + Answers []uint8 `db:"answers"` + CorrectAnswers []uint8 `db:"correct_answers"` } repositoryImpl struct { DB *storage.DB diff --git a/kyc/quiz/quiz.go b/kyc/quiz/quiz.go index fbd9aeda..59f26264 100644 --- a/kyc/quiz/quiz.go +++ b/kyc/quiz/quiz.go @@ -5,7 +5,6 @@ package quiz import ( "context" "fmt" - "strconv" stdlibtime "time" "github.com/hashicorp/go-multierror" @@ -78,18 +77,45 @@ func (r *repositoryImpl) CheckUserKYC(ctx context.Context, userID UserID) error return errors.Wrapf(err, "failed to get user by id: %v", userID) } - if profile.KYCStepPassed == nil || *profile.KYCStepPassed != (users.QuizKYCStep-1) { - state := "not set" - if profile.KYCStepPassed != nil { - state = strconv.Itoa(int(*profile.KYCStepPassed)) - } + return r.validateKycStep(profile.User) +} - return errors.Wrap(ErrInvalidKYCState, state) +//nolint:revive // . +func (r *repositoryImpl) validateKycStep(user *users.User) error { + if sessionCoolDown := stdlibtime.Duration(r.config.SessionCoolDownSeconds) * stdlibtime.Second; user.KYCStepPassed == nil || + *user.KYCStepPassed < users.QuizKYCStep-1 || + (user.KYCStepPassed != nil && + *user.KYCStepPassed == users.QuizKYCStep-1 && + user.KYCStepsLastUpdatedAt != nil && + len(*user.KYCStepsLastUpdatedAt) >= int(users.QuizKYCStep) && + !(*user.KYCStepsLastUpdatedAt)[users.QuizKYCStep-1].IsNil() && + time.Now().Sub(*(*user.KYCStepsLastUpdatedAt)[users.QuizKYCStep-1].Time) < sessionCoolDown) || + user.KYCStepPassed != nil && *user.KYCStepPassed >= users.QuizKYCStep { + return ErrInvalidKYCState } return nil } +func (r *repositoryImpl) SkipQuizSession(ctx context.Context, userID UserID) error { + if err := r.CheckUserKYC(ctx, userID); err != nil { + return err + } + + now := stdlibtime.Now() + for _, fn := range []func(context.Context, UserID, stdlibtime.Time, storage.QueryExecer) error{ + r.CheckUserFailedSession, + r.CheckUserActiveSession, + } { + if err := fn(ctx, userID, now, r.DB); err != nil { + return err + } + } + + return errors.Wrapf(r.UserMarkSessionAsFinished(ctx, userID, now, r.DB, false, true), + "failed to UserMarkSessionAsFinished for userID:%v", userID) +} + func (r *repositoryImpl) CheckUserFailedSession(ctx context.Context, userID UserID, now stdlibtime.Time, tx storage.QueryExecer) error { type failedSession struct { EndedAt stdlibtime.Time `db:"ended_at"` @@ -121,9 +147,9 @@ select max(ended_at) as ended_at from failed_quizz_sessions where user_id = $1 h func (r *repositoryImpl) CheckUserActiveSession(ctx context.Context, userID UserID, now stdlibtime.Time, tx storage.QueryExecer) error { type userSession struct { - StartedAt stdlibtime.Time `db:"started_at"` - Finished bool `db:"finished"` - FinishedSuccfully bool `db:"ended_successfully"` + StartedAt stdlibtime.Time `db:"started_at"` + Finished bool `db:"finished"` + FinishedSuccessfully bool `db:"ended_successfully"` } const stmt = `select started_at, ended_at is not null as finished, ended_successfully from quizz_sessions where user_id = $1` @@ -137,7 +163,7 @@ func (r *repositoryImpl) CheckUserActiveSession(ctx context.Context, userID User } if data.Finished { - if data.FinishedSuccfully { + if data.FinishedSuccessfully { return ErrSessionFinished } @@ -170,7 +196,7 @@ select id, options, question from questions where "language" = $1 order by rando } for i := range questions { - questions[i].Number = uint(i + 1) + questions[i].Number = uint8(i + 1) } return questions, nil @@ -271,7 +297,7 @@ func (r *repositoryImpl) StartQuizSession(ctx context.Context, userID UserID, la return quiz, err } -func calculateProgress(correctAnswers, currentAnswers []uint) (correctNum, incorrectNum uint8) { +func calculateProgress(correctAnswers, currentAnswers []uint8) (correctNum, incorrectNum uint8) { correct := correctAnswers if len(currentAnswers) < len(correctAnswers) { correct = correctAnswers[:len(currentAnswers)] @@ -296,8 +322,8 @@ func (r *repositoryImpl) CheckUserRunningSession( //nolint:funlen //. ) (userProgress, error) { type userSession struct { userProgress - Finished bool `db:"finished"` - FinishedSuccfully bool `db:"ended_successfully"` + Finished bool `db:"finished"` + FinishedSuccessfully bool `db:"ended_successfully"` } const stmt = ` select @@ -335,7 +361,7 @@ group by } if data.Finished { - if data.FinishedSuccfully { + if data.FinishedSuccessfully { return userProgress{}, ErrSessionFinished } @@ -350,12 +376,12 @@ group by return data.userProgress, nil } -func (*repositoryImpl) CheckQuestionNumber(ctx context.Context, questions []uint, num uint, tx storage.QueryExecer) (uint, error) { +func (*repositoryImpl) CheckQuestionNumber(ctx context.Context, questions []uint8, num uint8, tx storage.QueryExecer) (uint8, error) { type currentQuestion struct { CorrectOption uint8 `db:"correct_option"` } - if num == 0 || num > uint(len(questions)) { + if num == 0 || num > uint8(len(questions)) { return 0, ErrUnknownQuestionNumber } @@ -368,10 +394,10 @@ func (*repositoryImpl) CheckQuestionNumber(ctx context.Context, questions []uint return 0, errors.Wrap(err, "failed to get current question data") } - return uint(data.CorrectOption), nil + return data.CorrectOption, nil } -func (*repositoryImpl) UserAddAnswer(ctx context.Context, userID UserID, tx storage.QueryExecer, answer uint8) ([]uint, error) { +func (*repositoryImpl) UserAddAnswer(ctx context.Context, userID UserID, tx storage.QueryExecer, answer uint8) ([]uint8, error) { const stmt = ` update quizz_sessions set @@ -393,7 +419,7 @@ returning answers return data.Answers, nil } -func (*repositoryImpl) LoadQuestionByID(ctx context.Context, tx storage.QueryExecer, lang string, questionID uint) (*Question, error) { +func (*repositoryImpl) LoadQuestionByID(ctx context.Context, tx storage.QueryExecer, lang string, questionID uint8) (*Question, error) { const stmt = ` select id, options, question from questions where "language" = $1 and id = $2 ` @@ -406,7 +432,10 @@ select id, options, question from questions where "language" = $1 and id = $2 return question, nil } -func (*repositoryImpl) UserMarkSessionAsFinished(ctx context.Context, userID UserID, now stdlibtime.Time, tx storage.QueryExecer, successful bool) error { +//nolint:revive // . +func (r *repositoryImpl) UserMarkSessionAsFinished( + ctx context.Context, userID UserID, now stdlibtime.Time, tx storage.QueryExecer, successful, skipped bool, +) error { const stmt = ` with result as ( update quizz_sessions @@ -417,24 +446,24 @@ with result as ( user_id = $1 returning * ) -insert into failed_quizz_sessions (started_at, ended_at, questions, answers, language, user_id) +insert into failed_quizz_sessions (started_at, ended_at, questions, answers, language, user_id, skipped) select result.started_at, result.ended_at, result.questions, result.answers, result.language, - result.user_id + result.user_id, + $4 AS skipped from result where result.ended_successfully = false ` - - if _, err := storage.Exec(ctx, tx, stmt, userID, successful, now); err != nil { + if _, err := storage.Exec(ctx, tx, stmt, userID, successful, now, skipped); err != nil { return errors.Wrap(err, "failed to mark session as finished") } - return nil + return errors.Wrap(r.modifyUser(ctx, successful, time.New(now), userID), "failed to modifyUser") } func (r *repositoryImpl) fetchUserProfileForModify(ctx context.Context, userID UserID) (*users.User, error) { @@ -460,34 +489,34 @@ func (r *repositoryImpl) fetchUserProfileForModify(ctx context.Context, userID U return usr, nil } -func (r *repositoryImpl) modifyUser(ctx context.Context, now *time.Time, userID UserID) error { - usr, err := r.fetchUserProfileForModify(ctx, userID) +//nolint:revive // . +func (r *repositoryImpl) modifyUser(ctx context.Context, success bool, now *time.Time, userID UserID) error { + user, err := r.fetchUserProfileForModify(ctx, userID) if err != nil { return err } + usr := new(users.User) + usr.ID = user.ID - step := users.QuizKYCStep - usr.KYCStepPassed = &step + newKYCStep := users.QuizKYCStep + if success { + usr.KYCStepPassed = &newKYCStep + } - if len(*usr.KYCStepsLastUpdatedAt) < int(step) { + usr.KYCStepsLastUpdatedAt = user.KYCStepsLastUpdatedAt + if len(*usr.KYCStepsLastUpdatedAt) < int(newKYCStep) { *usr.KYCStepsLastUpdatedAt = append(*usr.KYCStepsLastUpdatedAt, now) } else { - (*usr.KYCStepsLastUpdatedAt)[int(step)-1] = now - } - if len(*usr.KYCStepsCreatedAt) < int(step) { - *usr.KYCStepsCreatedAt = append(*usr.KYCStepsCreatedAt, now) - } else { - (*usr.KYCStepsCreatedAt)[int(step)-1] = now + (*usr.KYCStepsLastUpdatedAt)[int(newKYCStep)-1] = now } return errors.Wrapf(r.Users.ModifyUser(ctx, usr, nil), "failed to modify user %#v", usr) } -func (r *repositoryImpl) ContinueQuizSession( //nolint:funlen,revive,gocognit //. +func (r *repositoryImpl) ContinueQuizSession( //nolint:funlen,revive //. ctx context.Context, userID UserID, - question uint, - answer uint8, + question, answer uint8, ) (quiz *Quiz, err error) { err = storage.DoInTransaction(ctx, r.DB, func(tx storage.QueryExecer) error { now := stdlibtime.Now().Truncate(stdlibtime.Second).UTC() @@ -498,7 +527,7 @@ func (r *repositoryImpl) ContinueQuizSession( //nolint:funlen,revive,gocognit // _, err = r.CheckQuestionNumber(ctx, progress.Questions, question, tx) if err != nil { return wrapErrorInTx(err) - } else if uint(len(progress.Answers)) != question-1 { + } else if uint8(len(progress.Answers)) != question-1 { return wrapErrorInTx(errors.Wrap(ErrUnknownQuestionNumber, "please answer questions in order")) } newAnswers, aErr := r.UserAddAnswer(ctx, userID, tx, answer) @@ -528,13 +557,10 @@ func (r *repositoryImpl) ContinueQuizSession( //nolint:funlen,revive,gocognit // if int(incorrectNum) > r.config.MaxWrongAnswersPerSession { quiz.Result = FailureResult - err = r.UserMarkSessionAsFinished(ctx, userID, now, tx, false) + err = r.UserMarkSessionAsFinished(ctx, userID, now, tx, false, false) } else { quiz.Result = SuccessResult - err = r.UserMarkSessionAsFinished(ctx, userID, now, tx, true) - if err == nil { - err = r.modifyUser(ctx, time.New(now), userID) - } + err = r.UserMarkSessionAsFinished(ctx, userID, now, tx, true, false) } return wrapErrorInTx(err) From ecb3e45618e82c44224c953bc809056017f604fd Mon Sep 17 00:00:00 2001 From: Ares <75481906+ice-ares@users.noreply.github.com> Date: Thu, 11 Jan 2024 19:32:17 +0200 Subject: [PATCH 4/4] a bit of a code cleanup --- application.yaml | 6 ++++++ kyc/quiz/.testdata/application.yaml | 11 ++--------- kyc/quiz/DDL.sql | 6 +++--- kyc/quiz/internal/.testdata/application.yaml | 8 -------- kyc/quiz/internal/contract.go | 3 --- kyc/quiz/internal/quiz.go | 3 --- kyc/quiz/quiz.go | 18 +++++++++--------- kyc/quiz/quiz_test.go | 12 ++++++------ 8 files changed, 26 insertions(+), 41 deletions(-) delete mode 100644 kyc/quiz/internal/.testdata/application.yaml delete mode 100644 kyc/quiz/internal/contract.go delete mode 100644 kyc/quiz/internal/quiz.go diff --git a/application.yaml b/application.yaml index e794848d..ae94375f 100644 --- a/application.yaml +++ b/application.yaml @@ -67,6 +67,12 @@ kyc/social: 5: https://www.facebook.com/reel/1463272597773681 6: https://www.facebook.com/reel/1463272597773681 allow-long-live-tokens: true +kyc/quiz: + wintr/connectors/storage/v2: *db + maxSessionDurationSeconds: 600 + maxQuestionsPerSession: 3 + maxWrongAnswersPerSession: 1 + sessionCoolDownSeconds: 3600 auth/email-link: wintr/connectors/storage/v2: *db fromEmailAddress: no-reply@ice.io diff --git a/kyc/quiz/.testdata/application.yaml b/kyc/quiz/.testdata/application.yaml index f32e7fa1..1f6c0163 100644 --- a/kyc/quiz/.testdata/application.yaml +++ b/kyc/quiz/.testdata/application.yaml @@ -16,15 +16,8 @@ users: &users - postgresql://root:pass@localhost:5432/ice kyc/quiz: + <<: *users maxSessionDurationSeconds: 600 maxQuestionsPerSession: 3 maxWrongAnswersPerSession: 1 - sessionCoolDownSeconds: 3600 - wintr/connectors/storage/v2: - runDDL: true - primaryURL: postgresql://root:pass@localhost:5432/ice - credentials: - user: root - password: pass - replicaURLs: - - postgresql://root:pass@localhost:5432/ice + sessionCoolDownSeconds: 3600 \ No newline at end of file diff --git a/kyc/quiz/DDL.sql b/kyc/quiz/DDL.sql index 522a63f7..f3834bac 100644 --- a/kyc/quiz/DDL.sql +++ b/kyc/quiz/DDL.sql @@ -2,7 +2,7 @@ create table if not exists questions ( - id bigint not null generated always as identity, + id bigint not null, correct_option smallint not null, options text[] not null, language text not null, @@ -11,7 +11,7 @@ create table if not exists questions primary key (language, id) ); -create table if not exists failed_quizz_sessions +create table if not exists failed_quiz_sessions ( started_at timestamp not null, ended_at timestamp not null, @@ -23,7 +23,7 @@ create table if not exists failed_quizz_sessions primary key (user_id, started_at) ); -create table if not exists quizz_sessions +create table if not exists quiz_sessions ( started_at timestamp not null, ended_at timestamp, diff --git a/kyc/quiz/internal/.testdata/application.yaml b/kyc/quiz/internal/.testdata/application.yaml deleted file mode 100644 index d7ebe6a9..00000000 --- a/kyc/quiz/internal/.testdata/application.yaml +++ /dev/null @@ -1,8 +0,0 @@ -# SPDX-License-Identifier: ice License 1.0 - -development: true -logger: - encoder: console - level: debug -self: - #Add your configs here. \ No newline at end of file diff --git a/kyc/quiz/internal/contract.go b/kyc/quiz/internal/contract.go deleted file mode 100644 index d5147d03..00000000 --- a/kyc/quiz/internal/contract.go +++ /dev/null @@ -1,3 +0,0 @@ -// SPDX-License-Identifier: ice License 1.0 - -package quiz diff --git a/kyc/quiz/internal/quiz.go b/kyc/quiz/internal/quiz.go deleted file mode 100644 index d5147d03..00000000 --- a/kyc/quiz/internal/quiz.go +++ /dev/null @@ -1,3 +0,0 @@ -// SPDX-License-Identifier: ice License 1.0 - -package quiz diff --git a/kyc/quiz/quiz.go b/kyc/quiz/quiz.go index 59f26264..5172bef7 100644 --- a/kyc/quiz/quiz.go +++ b/kyc/quiz/quiz.go @@ -122,7 +122,7 @@ func (r *repositoryImpl) CheckUserFailedSession(ctx context.Context, userID User } const stmt = ` -select max(ended_at) as ended_at from failed_quizz_sessions where user_id = $1 having max(ended_at) > $2 +select max(ended_at) as ended_at from failed_quiz_sessions where user_id = $1 having max(ended_at) > $2 ` term := now. @@ -151,7 +151,7 @@ func (r *repositoryImpl) CheckUserActiveSession(ctx context.Context, userID User Finished bool `db:"finished"` FinishedSuccessfully bool `db:"ended_successfully"` } - const stmt = `select started_at, ended_at is not null as finished, ended_successfully from quizz_sessions where user_id = $1` + const stmt = `select started_at, ended_at is not null as finished, ended_successfully from quiz_sessions where user_id = $1` data, err := storage.Get[userSession](ctx, tx, stmt, userID) if err != nil { @@ -220,8 +220,8 @@ func (*repositoryImpl) CreateSessionEntry( //nolint:revive //. tx storage.QueryExecer, ) error { const stmt = ` -insert into quizz_sessions (user_id, language, questions, started_at, answers) values ($1, $2, $3, $4, '{}'::smallint[]) - on conflict on constraint quizz_sessions_pkey do update +insert into quiz_sessions (user_id, language, questions, started_at, answers) values ($1, $2, $3, $4, '{}'::smallint[]) + on conflict on constraint quiz_sessions_pkey do update set started_at = excluded.started_at, questions = excluded.questions, @@ -335,7 +335,7 @@ select array_agg(questions.correct_option order by q.nr) as correct_answers, ended_successfully from - quizz_sessions session, + quiz_sessions session, questions inner join unnest(session.questions) with ordinality AS q(id, nr) on questions.id = q.id @@ -399,7 +399,7 @@ func (*repositoryImpl) CheckQuestionNumber(ctx context.Context, questions []uint func (*repositoryImpl) UserAddAnswer(ctx context.Context, userID UserID, tx storage.QueryExecer, answer uint8) ([]uint8, error) { const stmt = ` -update quizz_sessions +update quiz_sessions set answers = array_append(answers, $2) where @@ -438,7 +438,7 @@ func (r *repositoryImpl) UserMarkSessionAsFinished( ) error { const stmt = ` with result as ( - update quizz_sessions + update quiz_sessions set ended_at = $3, ended_successfully = $2 @@ -446,7 +446,7 @@ with result as ( user_id = $1 returning * ) -insert into failed_quizz_sessions (started_at, ended_at, questions, answers, language, user_id, skipped) +insert into failed_quiz_sessions (started_at, ended_at, questions, answers, language, user_id, skipped) select result.started_at, result.ended_at, @@ -572,7 +572,7 @@ func (r *repositoryImpl) ContinueQuizSession( //nolint:funlen,revive //. func (r *repositoryImpl) ResetQuizSession(ctx context.Context, userID UserID) error { // $1: user_id. const stmt = ` - delete from quizz_sessions + delete from quiz_sessions where user_id = $1 ` diff --git a/kyc/quiz/quiz_test.go b/kyc/quiz/quiz_test.go index bfcada48..8bb1d924 100644 --- a/kyc/quiz/quiz_test.go +++ b/kyc/quiz/quiz_test.go @@ -48,14 +48,14 @@ func helperSolveQuestion(t *testing.T, text string) uint8 { func helperForceFinishSession(t *testing.T, r *repositoryImpl, userID UserID, result bool) { t.Helper() - _, err := storage.Exec(context.TODO(), r.DB, "update quizz_sessions set ended_at = now(), ended_successfully = $2 where user_id = $1", userID, result) + _, err := storage.Exec(context.TODO(), r.DB, "update quiz_sessions set ended_at = now(), ended_successfully = $2 where user_id = $1", userID, result) require.NoError(t, err) } func helperForceResetSessionStartedAt(t *testing.T, r *repositoryImpl, userID UserID) { t.Helper() - _, err := storage.Exec(context.TODO(), r.DB, "update quizz_sessions set ended_at = NULL, started_at = to_timestamp(42) where user_id = $1", userID) + _, err := storage.Exec(context.TODO(), r.DB, "update quiz_sessions set ended_at = NULL, started_at = to_timestamp(42) where user_id = $1", userID) require.NoError(t, err) } @@ -66,7 +66,7 @@ func helperSessionReset(t *testing.T, r *repositoryImpl, userID UserID, full boo require.NoError(t, err) if full { - _, err = storage.Exec(context.TODO(), r.DB, "delete from failed_quizz_sessions where user_id = $1", userID) + _, err = storage.Exec(context.TODO(), r.DB, "delete from failed_quiz_sessions where user_id = $1", userID) require.NoError(t, err) } } @@ -167,8 +167,8 @@ func testManagerSessionStart(ctx context.Context, t *testing.T, r *repositoryImp session, err := r.StartQuizSession(ctx, "bogus", "en") require.NoError(t, err) - for i := uint(0); i < uint(session.Progress.MaxQuestions); i++ { - session, err = r.ContinueQuizSession(ctx, "bogus", i+uint(1), 0) + for i := uint8(0); i < uint8(session.Progress.MaxQuestions); i++ { + session, err = r.ContinueQuizSession(ctx, "bogus", i+uint8(1), 0) require.NoError(t, err) require.NotNil(t, session) } @@ -222,7 +222,7 @@ func testManagerSessionContinueErrors(ctx context.Context, t *testing.T, r *repo helperSessionReset(t, r, "bogus", true) _, err := r.StartQuizSession(ctx, "bogus", "en") require.NoError(t, err) - for _, n := range []uint{0, 4, 5, 6, 7, 10, 20, 100} { + for _, n := range []uint8{0, 4, 5, 6, 7, 10, 20, 100} { _, err = r.ContinueQuizSession(ctx, "bogus", n, 1) require.ErrorIs(t, err, ErrUnknownQuestionNumber) }