Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 84 additions & 14 deletions internal/api/http/routes/contests_management.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ type ContestsManagementRoute interface {
GetRegistrationRequests(w http.ResponseWriter, r *http.Request)
ApproveRegistrationRequest(w http.ResponseWriter, r *http.Request)
RejectRegistrationRequest(w http.ResponseWriter, r *http.Request)
UpdateTaskInContest(w http.ResponseWriter, r *http.Request)
GetContestSubmissions(w http.ResponseWriter, r *http.Request)
GetCreatedContests(w http.ResponseWriter, r *http.Request)
GetManageableContests(w http.ResponseWriter, r *http.Request)
Expand Down Expand Up @@ -187,16 +188,19 @@ func (cr *contestsManagementRouteImpl) DeleteContest(w http.ResponseWriter, r *h
//
// @Tags contests-management
// @Summary Get available tasks for a contest
// @Description Get all tasks that are NOT yet assigned to the specified contest (admin/teacher only)
//
// @Description Get all tasks that are NOT yet assigned to the specified contest with pagination (admin/teacher only)
// @Produce json
// @Param id path int true "Contest ID"
// @Failure 400 {object} httputils.APIError
// @Failure 403 {object} httputils.APIError
// @Failure 404 {object} httputils.APIError
// @Failure 405 {object} httputils.APIError
// @Failure 500 {object} httputils.APIError
// @Success 200 {object} httputils.APIResponse[[]schemas.Task]
// @Param id path int true "Contest ID"
// @Param limit query int false "Limit"
// @Param offset query int false "Offset"
// @Param sort query string false "Sort"
// @Param search query string false "Search tasks by title"
// @Failure 400 {object} httputils.APIError
// @Failure 403 {object} httputils.APIError
// @Failure 404 {object} httputils.APIError
// @Failure 405 {object} httputils.APIError
// @Failure 500 {object} httputils.APIError
// @Success 200 {object} httputils.APIResponse[schemas.PaginatedResult[[]schemas.Task]]
// @Router /contests-management/contests/{id}/tasks/assignable-tasks [get]
func (cr *contestsManagementRouteImpl) GetAssignableTasks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Expand All @@ -217,8 +221,11 @@ func (cr *contestsManagementRouteImpl) GetAssignableTasks(w http.ResponseWriter,

db := httputils.GetDatabase(r)
currentUser := httputils.GetCurrentUser(r)
queryParams := r.Context().Value(httputils.QueryParamsKey).(map[string]any)
paginationParams := httputils.ExtractPaginationParams(queryParams)
search, _ := queryParams["search"].(string)

tasks, err := cr.contestService.GetAssignableTasks(db, currentUser, contestID)
tasks, err := cr.contestService.GetAssignableTasks(db, currentUser, contestID, paginationParams, search)
if err != nil {
httputils.HandleServiceError(w, err, db, cr.logger)
return
Expand Down Expand Up @@ -338,6 +345,61 @@ func (cr *contestsManagementRouteImpl) RemoveTaskFromContest(w http.ResponseWrit
httputils.ReturnSuccess(w, http.StatusOK, httputils.NewMessageResponse("Tasks removed from contest successfully"))
}

// UpdateTaskInContest godoc
//
// @Tags contests-management
// @Summary Update a task's schedule in a contest
// @Description Update the start and end time of a task in a contest (only accessible by contest collaborators with edit permission)
// @Accept json
// @Produce json
// @Param id path int true "Contest ID"
// @Param task_id path int true "Task ID"
// @Param body body schemas.UpdateTaskInContest true "Update Task Schedule"
// @Failure 400 {object} httputils.ValidationErrorResponse
// @Failure 403 {object} httputils.APIError
// @Failure 404 {object} httputils.APIError
// @Failure 405 {object} httputils.APIError
// @Failure 500 {object} httputils.APIError
// @Success 200 {object} httputils.APIResponse[httputils.MessageResponse]
// @Router /contests-management/contests/{id}/tasks/{task_id} [put]
func (cr *contestsManagementRouteImpl) UpdateTaskInContest(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
httputils.ReturnError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}

contestStr := httputils.GetPathValue(r, "id")
contestID, err := strconv.ParseInt(contestStr, 10, 64)
if err != nil {
httputils.ReturnError(w, http.StatusBadRequest, "Invalid contest ID")
return
}

taskStr := httputils.GetPathValue(r, "task_id")
taskID, err := strconv.ParseInt(taskStr, 10, 64)
if err != nil {
httputils.ReturnError(w, http.StatusBadRequest, "Invalid task ID")
return
}

var request schemas.UpdateTaskInContest
if err := httputils.ShouldBindJSON(r.Body, &request); err != nil {
httputils.HandleValidationError(w, err)
return
}

db := httputils.GetDatabase(r)
currentUser := httputils.GetCurrentUser(r)

err = cr.contestService.UpdateTaskInContest(db, currentUser, contestID, taskID, &request)
if err != nil {
httputils.HandleServiceError(w, err, db, cr.logger)
return
}

httputils.ReturnSuccess(w, http.StatusOK, httputils.NewMessageResponse("Task schedule updated successfully"))
}

// GetRegistrationRequests godoc
//
// @Tags contests-management
Expand Down Expand Up @@ -478,16 +540,20 @@ func (cr *contestsManagementRouteImpl) RejectRegistrationRequest(w http.Response
//
// @Tags contests-management
// @Summary Get tasks for a contest
// @Description Get all tasks associated with a specific contest
// @Description Get all tasks associated with a specific contest with pagination
//
// @Produce json
// @Param id path int true "Contest ID"
// @Param id path int true "Contest ID"
// @Param limit query int false "Limit"
// @Param offset query int false "Offset"
// @Param sort query string false "Sort"
// @Param search query string false "Search tasks by title"
// @Failure 400 {object} httputils.APIError
// @Failure 403 {object} httputils.APIError
// @Failure 404 {object} httputils.APIError
// @Failure 405 {object} httputils.APIError
// @Failure 500 {object} httputils.APIError
// @Success 200 {object} httputils.APIResponse[[]schemas.ContestTask]
// @Success 200 {object} httputils.APIResponse[schemas.PaginatedResult[[]schemas.ContestTask]]
// @Router /contests-management/contests/{id}/tasks [get]
func (cr *contestsManagementRouteImpl) GetContestTasks(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
Expand All @@ -508,8 +574,11 @@ func (cr *contestsManagementRouteImpl) GetContestTasks(w http.ResponseWriter, r

db := httputils.GetDatabase(r)
currentUser := httputils.GetCurrentUser(r)
queryParams := r.Context().Value(httputils.QueryParamsKey).(map[string]any)
paginationParams := httputils.ExtractPaginationParams(queryParams)
search, _ := queryParams["search"].(string)

tasks, err := cr.contestService.GetTasksForContest(db, currentUser, contestID)
tasks, err := cr.contestService.GetTasksForContest(db, currentUser, contestID, paginationParams, search)
if err != nil {
httputils.HandleServiceError(w, err, db, cr.logger)
return
Expand Down Expand Up @@ -1340,6 +1409,7 @@ func RegisterContestsManagementRoute(mux *mux.Router, route ContestsManagementRo
})

mux.HandleFunc("/contests/{id}/tasks/assignable-tasks", route.GetAssignableTasks)
mux.HandleFunc("/contests/{id}/tasks/{task_id}", route.UpdateTaskInContest)

mux.HandleFunc("/contests/{id}/tasks", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
Expand Down
5 changes: 5 additions & 0 deletions package/domain/schemas/contest.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ type AddTaskToContest struct {
EndAt OptionalTime `json:"endAt"`
}

type UpdateTaskInContest struct {
StartAt OptionalTime `json:"startAt"`
EndAt OptionalTime `json:"endAt"`
}

type RegistrationRequest struct {
ID int64 `json:"id"`
ContestID int64 `json:"contestId"`
Expand Down
13 changes: 7 additions & 6 deletions package/domain/schemas/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,13 @@ type EditTask struct {
}

type Task struct {
ID int64 `json:"id"`
Title string `json:"title"`
CreatedBy int64 `json:"createdBy"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
IsVisible bool `json:"isVisible"`
ID int64 `json:"id"`
Title string `json:"title"`
CreatedBy int64 `json:"createdBy"`
CreatorName string `json:"creatorName"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
IsVisible bool `json:"isVisible"`
}

// Struct to embed basic task info
Expand Down
122 changes: 106 additions & 16 deletions package/repository/contest.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package repository

import (
"fmt"
"strings"

"github.com/mini-maxit/backend/internal/database"
"github.com/mini-maxit/backend/package/domain/models"
Expand Down Expand Up @@ -54,18 +55,22 @@ type ContestRepository interface {
IsUserParticipant(db database.Database, contestID int64, userID int64) (bool, error)
// GetTasksForContest retrieves all tasks assigned to a contest
GetTasksForContest(db database.Database, contestID int64) ([]models.Task, error)
// GetContestTasksWithSettings retrieves contest-task relations with timing flags and associated task
GetContestTasksWithSettings(db database.Database, contestID int64) ([]models.ContestTask, error)
// GetContestTasksWithSettings retrieves contest-task relations with timing flags and associated task (paginated)
GetContestTasksWithSettings(db database.Database, contestID int64, limit, offset int, sort, search string) ([]models.ContestTask, int64, error)
// GetVisibleContestTasksWithSettings retrieves visible contest-task relations with timing flags and associated task
GetVisibleContestTasksWithSettings(db database.Database, contestID int64) ([]models.ContestTask, error)
// GetTasksForContestWithStats retrieves all tasks assigned to a contest with submission statistics for a user
GetTasksForContestWithStats(db database.Database, contestID, userID int64) ([]models.Task, error)
// GetAssignableTasks retrieves all tasks NOT assigned to a contest
GetAssignableTasks(db database.Database, contestID int64) ([]models.Task, error)
// GetAssignableTasks retrieves all tasks NOT assigned to a contest (paginated)
GetAssignableTasks(db database.Database, contestID int64, limit, offset int, sort, search string) ([]models.Task, int64, error)
// GetContestsForUserWithStats retrieves contests with stats a user is participating in
GetContestsForUserWithStats(db database.Database, userID int64) ([]models.ParticipantContestStats, error)
// AddTasksToContest assigns tasks to a contest
AddTaskToContest(db database.Database, taskContest models.ContestTask) error
// UpdateTaskInContest updates a task's schedule in a contest
UpdateTaskInContest(db database.Database, taskContest models.ContestTask) error
// IsTaskInContest checks if a task is assigned to a contest
IsTaskInContest(db database.Database, contestID, taskID int64) (bool, error)
// RemoveTaskFromContest removes a task from a contest
RemoveTaskFromContest(db database.Database, contestID, taskID int64) error
// GetRegistrationRequests retrieves 'status' registration requests for a contest
Expand Down Expand Up @@ -634,18 +639,65 @@ func (cr *contestRepository) GetUpcomingContestsWithStats(db database.Database,
}

// GetContestTasksWithSettings retrieves contest-task relations (with timing flags) and preloads the associated Task
func (cr *contestRepository) GetContestTasksWithSettings(db database.Database, contestID int64) ([]models.ContestTask, error) {
func (cr *contestRepository) GetContestTasksWithSettings(db database.Database, contestID int64, limit, offset int, sort, search string) ([]models.ContestTask, int64, error) {
tx := db.GetInstance()
var relations []models.ContestTask
err := tx.Unscoped().Model(&models.ContestTask{}).
Where("contest_id = ?", contestID).
var totalCount int64

taskTable := database.ResolveTableName(tx, &models.Task{})
contestTaskTable := database.ResolveTableName(tx, &models.ContestTask{})

base := tx.Unscoped().Model(&models.ContestTask{}).
Joins(fmt.Sprintf("JOIN %s ON %s.id = %s.task_id", taskTable, taskTable, contestTaskTable)).
Where(fmt.Sprintf("%s.contest_id = ?", contestTaskTable), contestID)

if search != "" {
base = base.Where(fmt.Sprintf("%s.title ILIKE ?", taskTable), "%"+search+"%")
}

if err := base.Count(&totalCount).Error; err != nil {
return nil, 0, err
}

query := base
if sort != "" {
query = query.Order(qualifyContestTaskSort(sort, contestTaskTable, taskTable))
}

err := query.
Limit(limit).
Offset(offset).
Preload("Task").
Preload("Task.Author").
Find(&relations).Error
if err != nil {
return nil, err
return nil, 0, err
}
return relations, nil
return relations, totalCount, nil
}

// qualifyContestTaskSort qualifies sort fields with their table prefix for a joined contest_tasks/tasks query
func qualifyContestTaskSort(sortBy, contestTaskTable, taskTable string) string {
fieldMap := map[string]string{
"id": contestTaskTable + ".task_id",
"task_id": contestTaskTable + ".task_id",
"start_at": contestTaskTable + ".start_at",
"end_at": contestTaskTable + ".end_at",
"title": taskTable + ".title",
}
parts := strings.Split(sortBy, ":")
if len(parts) != 2 {
return sortBy
}
field, ok := fieldMap[parts[0]]
if !ok {
return sortBy
}
dir := parts[1]
if dir != "asc" && dir != "desc" {
dir = "asc"
}
return field + " " + dir
}

// GetVisibleContestTasksWithSettings retrieves visible contest-task relations (with timing flags) and preloads the associated Task
Expand Down Expand Up @@ -689,20 +741,35 @@ func (cr *contestRepository) GetTasksForContestWithStats(db database.Database, c
return tasks, nil
}

func (cr *contestRepository) GetAssignableTasks(db database.Database, contestID int64) ([]models.Task, error) {
func (cr *contestRepository) GetAssignableTasks(db database.Database, contestID int64, limit, offset int, sort, search string) ([]models.Task, int64, error) {
tx := db.GetInstance()
var tasks []models.Task
err := tx.Model(&models.Task{}).
var totalCount int64

base := tx.Model(&models.Task{}).
Where("id NOT IN (?)",
tx.Table(database.ResolveTableName(tx, &models.ContestTask{})).
Select("task_id").
Where("contest_id = ?", contestID),
).
Find(&tasks).Error
Where("contest_id = ?", contestID))

if search != "" {
base = base.Where("title ILIKE ?", "%"+search+"%")
}

if err := base.Count(&totalCount).Error; err != nil {
return nil, 0, err
}

query, err := utils.ApplyPaginationAndSort(base, limit, offset, sort)
if err != nil {
return nil, err
return nil, 0, err
}
return tasks, nil

err = query.Preload("Author").Find(&tasks).Error
if err != nil {
return nil, 0, err
}
return tasks, totalCount, nil
}

func (cr *contestRepository) GetContestsForUserWithStats(db database.Database, userID int64) ([]models.ParticipantContestStats, error) {
Expand Down Expand Up @@ -840,6 +907,29 @@ func (cr *contestRepository) RemoveTaskFromContest(db database.Database, contest
return err
}

func (cr *contestRepository) UpdateTaskInContest(db database.Database, taskContest models.ContestTask) error {
tx := db.GetInstance()
err := tx.Model(&models.ContestTask{}).
Where("contest_id = ? AND task_id = ?", taskContest.ContestID, taskContest.TaskID).
Updates(map[string]any{
"start_at": taskContest.StartAt,
"end_at": taskContest.EndAt,
}).Error
return err
}

func (cr *contestRepository) IsTaskInContest(db database.Database, contestID, taskID int64) (bool, error) {
tx := db.GetInstance()
var count int64
err := tx.Model(&models.ContestTask{}).
Where("contest_id = ? AND task_id = ?", contestID, taskID).
Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}

func (cr *contestRepository) GetRegistrationRequests(db database.Database, contestID int64, status types.RegistrationRequestStatus) ([]models.ContestRegistrationRequests, error) {
tx := db.GetInstance()
var requests []models.ContestRegistrationRequests
Expand Down
Loading
Loading