package api import ( "bytes" "context" "errors" "log" "sort" "time" codequestpb "github.com/hhhapz/codequest/api/v1" "github.com/hhhapz/codequest/models" "github.com/hhhapz/codequest/question" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) var ( Start time.Time End time.Time ) func active() bool { now := time.Now() return Start.Before(now) && End.After(now) } type QuestService struct { codequestpb.UnsafeQuestServiceServer questStore QuestStore userStore UserStore cooldownStore CooldownStore } func (qs *QuestService) Questions(ctx context.Context, req *codequestpb.QuestionsRequest) (*codequestpb.QuestionsResponse, error) { u := User(ctx) questions, err := qs.questStore.Questions(ctx, u) switch { case errors.As(err, &models.UserError{}): return nil, status.Error(codes.InvalidArgument, err.Error()) case err != nil: return nil, status.Errorf(codes.Internal, "could not get questions: internal error") } if !active() { questions = nil } res := new(codequestpb.QuestionsResponse) for _, q := range questions { res.Questions = append(res.Questions, &codequestpb.Question{ ID: q.Question.ID, Title: q.Name, Difficulty: codequestpb.Difficulty(q.Level), Part1: &codequestpb.PartData{ Completed: q.Part1.Completed, PointsWorth: int32(q.Part1.PointsWorth), }, Part2: &codequestpb.PartData{ Completed: q.Part2.Completed, PointsWorth: int32(q.Part2.PointsWorth), }, }) } q := res.Questions sort.Slice(q, func(i, j int) bool { var iSolved, jSolved int switch { case q[i].Part2.Completed: iSolved = 2 case q[i].Part1.Completed: iSolved = 1 } switch { case q[j].Part2.Completed: jSolved = 2 case q[j].Part1.Completed: jSolved = 1 } switch { // if progress on questions is the same, sort alphabetically case iSolved == jSolved: return q[i].Text < q[j].Text // if both parts completed, always put at the end case iSolved == 2: return false case jSolved == 2: return true // show partway completed ones before those not started case iSolved == 1: return true case jSolved == 1: return false } return false }) return res, nil } func (qs *QuestService) QuestionByID(ctx context.Context, req *codequestpb.QuestionByIDRequest) (*codequestpb.Question, error) { u := User(ctx) if !active() { return nil, status.Errorf(codes.NotFound, "the contest is not active") } question, err := qs.questStore.Question(ctx, u, req.ID) switch { case errors.As(err, &models.UserError{}): return nil, status.Error(codes.InvalidArgument, err.Error()) case err != nil: log.Printf("could not fetch question(%q): %v", req.ID, err) return nil, status.Errorf(codes.Internal, "could not get questions: internal error") } content := new(bytes.Buffer) err = question.Text.Execute(content, question) if err != nil { log.Printf("could gen write question %s template: %v", req.ID, err) return nil, status.Errorf(codes.Internal, "could not get question: internal error") } q := &codequestpb.Question{ ID: question.Question.ID, Title: question.Name, Text: content.String(), Part1: &codequestpb.PartData{ Completed: question.Part1.Completed, PointsWorth: int32(question.Part1.PointsWorth), }, Part2: &codequestpb.PartData{ Completed: question.Part2.Completed, PointsWorth: int32(question.Part2.PointsWorth), }, } return q, nil } func (qs *QuestService) QuestionInput(ctx context.Context, req *codequestpb.QuestionInputRequest) (*codequestpb.QuestionInput, error) { u := User(ctx) if !active() { return nil, status.Errorf(codes.NotFound, "the contest is not active") } question, err := qs.questStore.Question(ctx, u, req.ID) switch { case errors.As(err, &models.UserError{}): return nil, status.Error(codes.InvalidArgument, err.Error()) case err != nil: log.Printf("could not get question %s: %v", req.ID, err) return nil, status.Errorf(codes.Internal, "could not get question: internal error") } inp := question.Question.Generate(u) return &codequestpb.QuestionInput{ ID: question.Question.ID, Input: inp, }, nil } func (qs *QuestService) Submit(ctx context.Context, req *codequestpb.SubmitRequest) (*codequestpb.SubmitResponse, error) { u := User(ctx) if !active() { return nil, status.Errorf(codes.NotFound, "the contest is not active") } if req.Part != 1 && req.Part != 2 { return nil, status.Errorf(codes.NotFound, "invalid part number") } q, err := qs.questStore.Question(ctx, u, req.ID) switch { case errors.As(err, &models.UserError{}): return nil, status.Error(codes.InvalidArgument, err.Error()) case err != nil: return nil, status.Errorf(codes.Internal, "could not get questions: internal error") } completed, attempts, err := qs.questStore.Submissions(ctx, u, req.ID, question.Part(req.Part)) switch { case errors.As(err, &models.UserError{}): return nil, status.Errorf(codes.InvalidArgument, "%v", err) case err != nil: log.Printf("could not get submissions: %v", err) return nil, status.Errorf(codes.Internal, "could not get submissions: internal error") } if completed { return nil, status.Errorf(codes.AlreadyExists, "question %s (part %d) already completed", req.ID, req.Part) } // TODO: cooldowns t := qs.cooldownStore.Check(u.ID, q.Question.ID) if t != 0 { return nil, status.Errorf( codes.FailedPrecondition, "you must wait %s before trying this question again", t.Truncate(time.Second), ) } ok := q.Question.Validate(u, question.Part(req.Part), req.Body.Answer) var points int if ok { points = 5000 - (attempts * 100) if points < 2000 { points = 2000 } } else { qs.cooldownStore.NewAttempt(u.ID, q.Question.ID, attempts) } qa := &models.QuestionAttempt{ UserID: u.ID, QuestionID: q.Question.ID, QuestionPart: int(req.Part), Correct: ok, PointsAwarded: points, Answer: req.Body.Answer, Code: req.Body.Code, SubmittedAt: models.NewTime(time.Now()), } qs.questStore.AddSubmission(ctx, qa) res := &codequestpb.SubmitResponse{ Correct: qa.Correct, Points: int32(qa.PointsAwarded), } return res, nil } func (qs *QuestService) Leaderboard(ctx context.Context, req *codequestpb.LeaderboardRequest) (*codequestpb.LeaderboardResponse, error) { entries, err := qs.questStore.Leaderboard(ctx) if err != nil { log.Printf("could not get leaderboard: %v", err) return nil, status.Errorf(codes.Internal, "could not get leaderboard: internal error") } var lbr codequestpb.LeaderboardResponse for _, le := range entries { lbr.Leaderboard = append(lbr.Leaderboard, &codequestpb.LeaderboardEntry{ Username: le.Name, Points: int32(le.Points), }) } return &lbr, nil }