From 1ec5e352cc7bff3791c8ebb5c8e3f578004e2a13 Mon Sep 17 00:00:00 2001 From: shreyas-omkar Date: Wed, 19 Nov 2025 20:59:33 +0530 Subject: [PATCH] feat:Added Testcase and Answers for each problem to be stored in S3 --- internal/controllers/contest-controller.go | 57 +++++++ .../000018_problem_add_testcases.down.sql | 1 + .../000018_problem_add_testcases.up.sql | 1 + internal/models/dto/problem-dto.go | 36 +++-- internal/models/problem.go | 1 + internal/routes/admin-routes.go | 2 + internal/s3/s3.go | 45 ++++++ internal/services/contest-service.go | 143 +++++++++++++++++- internal/stores/problem-store.go | 9 +- 9 files changed, 272 insertions(+), 23 deletions(-) create mode 100644 internal/migrations/000018_problem_add_testcases.down.sql create mode 100644 internal/migrations/000018_problem_add_testcases.up.sql diff --git a/internal/controllers/contest-controller.go b/internal/controllers/contest-controller.go index 817ca4b..755ecc5 100644 --- a/internal/controllers/contest-controller.go +++ b/internal/controllers/contest-controller.go @@ -404,3 +404,60 @@ func (cc *ContestController) GetContestRegistrations(ctx echo.Context) error { return ctx.JSON(http.StatusOK, registrations) } + +func (cc *ContestController) GetProblemTestcases(ctx echo.Context) error { + contestID := ctx.Param("contestid") + problemID := ctx.Param("problemid") + userID := ctx.Get(common.AUTH_USER_ID).(string) + + err := cc.contestService.GetProblemVisibility(ctx.Request().Context(), contestID, userID) + if err != nil { + if err == common.ContestNotFoundError { + return ctx.JSON(http.StatusNotFound, map[string]string{ + "error": common.ContestNotFoundError.Error(), + }) + } else if err == common.UserNotRegisteredError || + err == common.ContestNotRunningError { + return ctx.JSON(http.StatusForbidden, map[string]string{ + "error": err.Error(), + }) + } + + return ctx.JSON(http.StatusInternalServerError, map[string]string{ + "error": common.FetchContestFailedError.Error(), + }) + } + + testcases, err := cc.contestService.GetProblemTestcases(ctx.Request().Context(), contestID, problemID) + if err != nil { + if err == common.ContestNotFoundError { + return ctx.JSON(http.StatusNotFound, map[string]string{ + "error": common.ContestNotFoundError.Error(), + }) + } + return ctx.JSON(http.StatusInternalServerError, map[string]string{ + "error": "failed to load problem testcases", + }) + } + + return ctx.JSON(http.StatusOK, testcases) +} + +func (cc *ContestController) GetProblemAnswers(ctx echo.Context) error { + contestID := ctx.Param("contestid") + problemID := ctx.Param("problemid") + + answers, err := cc.contestService.GetProblemAnswers(ctx.Request().Context(), contestID, problemID) + if err != nil { + if err == common.ContestNotFoundError { + return ctx.JSON(http.StatusNotFound, map[string]string{ + "error": common.ContestNotFoundError.Error(), + }) + } + return ctx.JSON(http.StatusInternalServerError, map[string]string{ + "error": "failed to get problem answers", + }) + } + + return ctx.JSON(http.StatusOK, answers) +} diff --git a/internal/migrations/000018_problem_add_testcases.down.sql b/internal/migrations/000018_problem_add_testcases.down.sql new file mode 100644 index 0000000..2ebfa67 --- /dev/null +++ b/internal/migrations/000018_problem_add_testcases.down.sql @@ -0,0 +1 @@ +ALTER TABLE problems DROP COLUMN testcases; diff --git a/internal/migrations/000018_problem_add_testcases.up.sql b/internal/migrations/000018_problem_add_testcases.up.sql new file mode 100644 index 0000000..8d838dd --- /dev/null +++ b/internal/migrations/000018_problem_add_testcases.up.sql @@ -0,0 +1 @@ +ALTER TABLE problems ADD COLUMN testcases TEXT; diff --git a/internal/models/dto/problem-dto.go b/internal/models/dto/problem-dto.go index b838a86..c19dfda 100644 --- a/internal/models/dto/problem-dto.go +++ b/internal/models/dto/problem-dto.go @@ -10,18 +10,32 @@ type ProblemOverview struct { } type GetProblemStatementResponse struct { - ProblemID string `json:"problem_id"` - ContestID string `json:"contest_id"` - Name string `json:"name"` - Description string `json:"description"` - Score int `json:"score"` - Type models.SubmissionType `json:"type"` + ProblemID string `json:"problem_id"` + ContestID string `json:"contest_id"` + Name string `json:"name"` + Description string `json:"description"` + Score int `json:"score"` + Type models.SubmissionType `json:"type"` + Testcases []TestCaseResponse `json:"testcases,omitempty"` + TestcasesKey string `json:"-"` } type CreateProblemRequest struct { - Name string `json:"name" validate:"required"` - Description string `json:"description" validate:"required"` - Score int `json:"score" validate:"required,gt=0"` - Type models.SubmissionType `json:"type" validate:"required,oneof=mcq code"` - Answer []int `json:"answer"` // required only for MCQ + Name string `json:"name" validate:"required"` + Description string `json:"description" validate:"required"` + Score int `json:"score" validate:"required,gt=0"` + Type models.SubmissionType `json:"type" validate:"required,oneof=mcq code"` + Answer []int `json:"answer"` // required only for MCQ + Testcases []CreateTestCaseRequest `json:"testcases,omitempty"` +} + +type CreateTestCaseRequest struct { + Input string `json:"input" validate:"required"` + ExpectedOutput string `json:"expected_output" validate:"required"` +} + +type TestCaseResponse struct { + Index int `json:"index"` + Input string `json:"input"` + ExpectedOutput string `json:"expected_output,omitempty"` } diff --git a/internal/models/problem.go b/internal/models/problem.go index bf2e51e..1baf8d0 100644 --- a/internal/models/problem.go +++ b/internal/models/problem.go @@ -9,4 +9,5 @@ type Problem struct { Type SubmissionType `json:"type"` // "code" or "mcq" HasMultipleAnswers bool `json:"has_multiple_answers"` Answer []int `json:"answer"` + Testcases string `json:"testcases"` } diff --git a/internal/routes/admin-routes.go b/internal/routes/admin-routes.go index 066de1f..d5fb6d5 100644 --- a/internal/routes/admin-routes.go +++ b/internal/routes/admin-routes.go @@ -39,6 +39,8 @@ func AddAdminRoutes( adminGroup.POST("/:contestid/problem", contestController.HandleCreateProblem) adminGroup.PUT("/:contestid/:problemid", contestController.HandleUpdateProblem) adminGroup.DELETE("/:contestid/:problemid", contestController.HandleDeleteProblem) + adminGroup.GET("/:contestid/:problemid/testcases", contestController.GetProblemTestcases) + adminGroup.GET("/:contestid/:problemid/answers", contestController.GetProblemAnswers) //Leaderboard/User Management adminGroup.PUT("/:contestid/leaderboard/:userid", contestController.HandleUpdateLeaderboardUser) diff --git a/internal/s3/s3.go b/internal/s3/s3.go index c20304a..227c894 100644 --- a/internal/s3/s3.go +++ b/internal/s3/s3.go @@ -97,3 +97,48 @@ func (s *S3) DeleteObject(ctx context.Context, key string) error { } return nil } + +func (s *S3) ListObjects(ctx context.Context, prefix string) ([]string, error) { + var keys []string + + paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{ + Bucket: aws.String(s.Bucket), + Prefix: aws.String(prefix), + }) + + for paginator.HasMorePages() { + out, err := paginator.NextPage(ctx) + if err != nil { + log.Errorf("s3: list objects failed: %v", err) + return nil, err + } + for _, obj := range out.Contents { + if obj.Key != nil { + keys = append(keys, *obj.Key) + } + } + } + return keys, nil +} + +func (s *S3) DeletePrefix(ctx context.Context, prefix string) error { + out, err := s.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String(s.Bucket), + Prefix: aws.String(prefix), + }) + if err != nil { + return err + } + + for _, obj := range out.Contents { + _, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(s.Bucket), + Key: obj.Key, + }) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/services/contest-service.go b/internal/services/contest-service.go index b66b5b0..183e92f 100644 --- a/internal/services/contest-service.go +++ b/internal/services/contest-service.go @@ -9,6 +9,7 @@ import ( "context" "encoding/json" "slices" + "strings" "fmt" @@ -104,7 +105,7 @@ func (cs *ContestService) CreateProblem(ctx context.Context, contestID string, r HasMultipleAnswers: req.Type == "mcq" && len(req.Answer) > 1, } - s3Key := fmt.Sprintf("problems/%s/%s.json", problem.ContestID, problem.ID) + s3Key := fmt.Sprintf("problems/%s/%s/description.json", contestID, problem.ID) payload := map[string]string{ "description": req.Description, @@ -118,6 +119,37 @@ func (cs *ContestService) CreateProblem(ctx context.Context, contestID string, r problem.Description = s3Key + if req.Type == "code" { + + testcasesKey := fmt.Sprintf("problems/%s/%s/testcases.json", contestID, problem.ID) + answersKey := fmt.Sprintf("problems/%s/%s/answers.json", contestID, problem.ID) + + var tcArray []map[string]interface{} + var ansArray []string + + for i, tc := range req.Testcases { + tcArray = append(tcArray, map[string]interface{}{ + "index": i, + "input": tc.Input, + }) + + ansArray = append(ansArray, tc.ExpectedOutput) + } + + tcBytes, _ := json.Marshal(tcArray) + ansBytes, _ := json.Marshal(ansArray) + + if err := cs.s3.PutObject(ctx, testcasesKey, string(tcBytes)); err != nil { + return nil, err + } + + if err := cs.s3.PutObject(ctx, answersKey, string(ansBytes)); err != nil { + return nil, err + } + + problem.Testcases = testcasesKey + } + if err := cs.stores.Problems.CreateProblem(ctx, problem); err != nil { return nil, err } @@ -146,6 +178,36 @@ func (cs *ContestService) UpdateProblem(ctx context.Context, contestID string, p return nil, err } + var testcasesKey string = meta.TestcasesKey + + if req.Type == "code" && len(req.Testcases) > 0 { + + testcasesKey = fmt.Sprintf("problems/%s/%s/testcases.json", contestID, problemID) + answersKey := fmt.Sprintf("problems/%s/%s/answers.json", contestID, problemID) + + var tcArray []map[string]interface{} + var ansArray []string + + for i, tc := range req.Testcases { + tcArray = append(tcArray, map[string]interface{}{ + "index": i, + "input": tc.Input, + }) + + ansArray = append(ansArray, tc.ExpectedOutput) + } + + tcBytes, _ := json.Marshal(tcArray) + if err := cs.s3.PutObjectOverwrite(ctx, testcasesKey, string(tcBytes)); err != nil { + return nil, err + } + + ansBytes, _ := json.Marshal(ansArray) + if err := cs.s3.PutObjectOverwrite(ctx, answersKey, string(ansBytes)); err != nil { + return nil, err + } + } + hasMultiple := req.Type == "mcq" && len(req.Answer) > 1 problem := &models.Problem{ @@ -157,6 +219,7 @@ func (cs *ContestService) UpdateProblem(ctx context.Context, contestID string, p Type: req.Type, Answer: req.Answer, HasMultipleAnswers: hasMultiple, + Testcases: testcasesKey, } if err := cs.stores.Problems.UpdateProblem(ctx, problem); err != nil { @@ -167,19 +230,19 @@ func (cs *ContestService) UpdateProblem(ctx context.Context, contestID string, p func (cs *ContestService) DeleteProblem(ctx context.Context, contestID string, problemID string) error { - meta, err := cs.stores.Problems.GetProblem(ctx, problemID, contestID) + _, err := cs.stores.Problems.GetProblem(ctx, problemID, contestID) if err != nil { return err } - s3Key := meta.Description - if err := cs.stores.Problems.DeleteProblem(ctx, contestID, problemID); err != nil { return err } - if err := cs.s3.DeleteObject(ctx, s3Key); err != nil { - log.Errorf("failed to delete S3 file for problem %s: %v", problemID, err) + prefix := fmt.Sprintf("problems/%s/%s/", contestID, problemID) + + if err := cs.s3.DeleteObject(ctx, prefix); err != nil { + log.Errorf("failed to delete S3 folder for problem %s: %v", problemID, err) } return nil @@ -220,15 +283,29 @@ func (cs *ContestService) GetContestProblem(ctx context.Context, contestID strin return nil, err } - s3Key := meta.Description + descKey := meta.Description - desc, err := cs.s3.GetObject(ctx, s3Key) + desc, err := cs.s3.GetObject(ctx, descKey) if err != nil { return nil, err } meta.Description = desc + testcaseKey := meta.TestcasesKey + + testcase, err := cs.s3.GetObject(ctx, testcaseKey) + if err != nil { + return meta, nil + } + + var tcArr []dto.TestCaseResponse + if err := json.Unmarshal([]byte(testcase), &tcArr); err != nil { + return nil, err + } + + meta.Testcases = tcArr + return meta, nil } @@ -254,3 +331,53 @@ func (cs *ContestService) GetContest(ctx context.Context, contestID string, user func (cs *ContestService) GetContestRegistrations(ctx context.Context, contestID string) ([]dto.ContestRegistration, error) { return cs.stores.Contests.GetContestRegistrations(ctx, contestID) } + +func (cs *ContestService) GetProblemTestcases(ctx context.Context, contestID, problemID string) ([]dto.TestCaseResponse, error) { + + meta, err := cs.stores.Problems.GetProblem(ctx, problemID, contestID) + if err != nil { + return nil, err + } + + if meta.TestcasesKey == "" { + return []dto.TestCaseResponse{}, nil + } + + raw, err := cs.s3.GetObject(ctx, meta.TestcasesKey) + if err != nil { + return nil, err + } + + var arr []dto.TestCaseResponse + if err := json.Unmarshal([]byte(raw), &arr); err != nil { + return nil, err + } + + return arr, nil +} + +func (cs *ContestService) GetProblemAnswers(ctx context.Context, contestID, problemID string) ([]string, error) { + + meta, err := cs.stores.Problems.GetProblem(ctx, problemID, contestID) + if err != nil { + return nil, err + } + + if meta.TestcasesKey == "" { + return []string{}, nil + } + + answersKey := strings.Replace(meta.TestcasesKey, "testcases.json", "answers.json", 1) + + raw, err := cs.s3.GetObject(ctx, answersKey) + if err != nil { + return nil, err + } + + var arr []string + if err := json.Unmarshal([]byte(raw), &arr); err != nil { + return nil, err + } + + return arr, nil +} diff --git a/internal/stores/problem-store.go b/internal/stores/problem-store.go index 96e73ef..e40fddb 100644 --- a/internal/stores/problem-store.go +++ b/internal/stores/problem-store.go @@ -28,8 +28,8 @@ func (s *ProblemStore) CreateProblem(ctx context.Context, p *models.Problem) err } const q = ` - INSERT INTO problems (id, contest_id, name, score, type, answer, description, has_multiple_answers) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO problems (id, contest_id, name, score, type, answer, description, has_multiple_answers, testcases) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ` _, err := s.db.ExecContext(ctx, q, p.ID, @@ -40,6 +40,7 @@ func (s *ProblemStore) CreateProblem(ctx context.Context, p *models.Problem) err pq.Array(p.Answer), p.Description, p.HasMultipleAnswers, + p.Testcases, ) if err != nil { @@ -141,7 +142,7 @@ func (s *ProblemStore) GetProblemList(ctx context.Context, contestID string) ([] func (s *ProblemStore) GetProblem(ctx context.Context, problemID string, contestID string) (*dto.GetProblemStatementResponse, error) { const q = ` - SELECT id, contest_id, name, description, score, type + SELECT id, contest_id, name, description, score, type, testcases FROM problems WHERE id = $1 AND contest_id = $2 ` @@ -149,7 +150,7 @@ func (s *ProblemStore) GetProblem(ctx context.Context, problemID string, contest var p dto.GetProblemStatementResponse err := s.db.QueryRowContext(ctx, q, problemID, contestID).Scan( - &p.ProblemID, &p.ContestID, &p.Name, &p.Description, &p.Score, &p.Type, + &p.ProblemID, &p.ContestID, &p.Name, &p.Description, &p.Score, &p.Type, &p.TestcasesKey, ) if err != nil { if err == sql.ErrNoRows {