Spaces:
Sleeping
Sleeping
Commit ·
d0e1b44
1
Parent(s): 1b4f42f
Deploy files from GitHub repository
Browse files- .env.example +2 -3
- Quzuu_API_Collection.postman_collection.json +81 -3
- Quzuu_Dev_Environment.postman_environment.json +6 -0
- controllers/event_exam_controller.go +97 -64
- models/dto/event_dto.go +19 -13
- models/entity/entity.go +7 -0
- repositories/event_exam_attempt_repository.go +103 -2
- router/event_exam_proctoring_router.go +1 -1
- router/event_exam_router.go +1 -0
- services/event_exam_service.go +24 -2
.env.example
CHANGED
|
@@ -12,10 +12,9 @@ XENDIT_API_KEY =
|
|
| 12 |
XENDIT_CALLBACK_TOKEN =
|
| 13 |
MAILGUN_API_KEY =
|
| 14 |
MAILGUN_DOMAIN =
|
| 15 |
-
MAILGUN_SENDER =
|
| 16 |
MAILGUN_API_BASE_URL = https://api.mailgun.net
|
| 17 |
-
SMTP_HOST =
|
| 18 |
-
SMTP_PORT =
|
| 19 |
SMTP_USERNAME =
|
| 20 |
SMTP_PASSWORD =
|
| 21 |
SMTP_SENDER =
|
|
|
|
| 12 |
XENDIT_CALLBACK_TOKEN =
|
| 13 |
MAILGUN_API_KEY =
|
| 14 |
MAILGUN_DOMAIN =
|
|
|
|
| 15 |
MAILGUN_API_BASE_URL = https://api.mailgun.net
|
| 16 |
+
SMTP_HOST =
|
| 17 |
+
SMTP_PORT =
|
| 18 |
SMTP_USERNAME =
|
| 19 |
SMTP_PASSWORD =
|
| 20 |
SMTP_SENDER =
|
Quzuu_API_Collection.postman_collection.json
CHANGED
|
@@ -629,6 +629,73 @@
|
|
| 629 |
},
|
| 630 |
"response": []
|
| 631 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 632 |
{
|
| 633 |
"name": "Exam Scoreboard",
|
| 634 |
"request": {
|
|
@@ -641,7 +708,7 @@
|
|
| 641 |
}
|
| 642 |
],
|
| 643 |
"url": {
|
| 644 |
-
"raw": "{{base_url}}/api/v1/events/:event_slug/:exam_slug/scoreboard?limit=10&page=1&sortBy=score&
|
| 645 |
"host": [
|
| 646 |
"{{base_url}}"
|
| 647 |
],
|
|
@@ -664,15 +731,26 @@
|
|
| 664 |
"value": "1",
|
| 665 |
"description": "Page number"
|
| 666 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 667 |
{
|
| 668 |
"key": "sortBy",
|
| 669 |
"value": "score",
|
| 670 |
-
"description": "Sort field: 'score' (default) or '
|
| 671 |
},
|
| 672 |
{
|
| 673 |
-
"key": "
|
| 674 |
"value": "desc",
|
| 675 |
"description": "Sort order: 'asc' or 'desc'"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
}
|
| 677 |
],
|
| 678 |
"variable": [
|
|
|
|
| 629 |
},
|
| 630 |
"response": []
|
| 631 |
},
|
| 632 |
+
{
|
| 633 |
+
"name": "Event Scoreboard",
|
| 634 |
+
"request": {
|
| 635 |
+
"method": "GET",
|
| 636 |
+
"header": [
|
| 637 |
+
{
|
| 638 |
+
"key": "Authorization",
|
| 639 |
+
"value": "Bearer {{access_token}}",
|
| 640 |
+
"type": "text"
|
| 641 |
+
}
|
| 642 |
+
],
|
| 643 |
+
"url": {
|
| 644 |
+
"raw": "{{base_url}}/api/v1/events/:event_slug/scoreboard?limit=10&page=1&search=&sortBy=total_score&orderBy=desc",
|
| 645 |
+
"host": [
|
| 646 |
+
"{{base_url}}"
|
| 647 |
+
],
|
| 648 |
+
"path": [
|
| 649 |
+
"api",
|
| 650 |
+
"v1",
|
| 651 |
+
"events",
|
| 652 |
+
":event_slug",
|
| 653 |
+
"scoreboard"
|
| 654 |
+
],
|
| 655 |
+
"query": [
|
| 656 |
+
{
|
| 657 |
+
"key": "limit",
|
| 658 |
+
"value": "10",
|
| 659 |
+
"description": "Number of items per page (max 100)"
|
| 660 |
+
},
|
| 661 |
+
{
|
| 662 |
+
"key": "page",
|
| 663 |
+
"value": "1",
|
| 664 |
+
"description": "Page number"
|
| 665 |
+
},
|
| 666 |
+
{
|
| 667 |
+
"key": "search",
|
| 668 |
+
"value": "",
|
| 669 |
+
"description": "Search keyword"
|
| 670 |
+
},
|
| 671 |
+
{
|
| 672 |
+
"key": "sortBy",
|
| 673 |
+
"value": "total_score",
|
| 674 |
+
"description": "Sort field: 'total_score' (default), 'score', 'username', or 'full_name'"
|
| 675 |
+
},
|
| 676 |
+
{
|
| 677 |
+
"key": "orderBy",
|
| 678 |
+
"value": "desc",
|
| 679 |
+
"description": "Sort order: 'asc' or 'desc'"
|
| 680 |
+
},
|
| 681 |
+
{
|
| 682 |
+
"key": "order",
|
| 683 |
+
"value": "desc",
|
| 684 |
+
"disabled": true,
|
| 685 |
+
"description": "Alias for sort order (backward compatibility)"
|
| 686 |
+
}
|
| 687 |
+
],
|
| 688 |
+
"variable": [
|
| 689 |
+
{
|
| 690 |
+
"key": "event_slug",
|
| 691 |
+
"value": ""
|
| 692 |
+
}
|
| 693 |
+
]
|
| 694 |
+
},
|
| 695 |
+
"description": "Retrieve a paginated scoreboard of participants ranked by total score across all exams in an event"
|
| 696 |
+
},
|
| 697 |
+
"response": []
|
| 698 |
+
},
|
| 699 |
{
|
| 700 |
"name": "Exam Scoreboard",
|
| 701 |
"request": {
|
|
|
|
| 708 |
}
|
| 709 |
],
|
| 710 |
"url": {
|
| 711 |
+
"raw": "{{base_url}}/api/v1/events/:event_slug/:exam_slug/scoreboard?limit=10&page=1&search=&sortBy=score&orderBy=desc",
|
| 712 |
"host": [
|
| 713 |
"{{base_url}}"
|
| 714 |
],
|
|
|
|
| 731 |
"value": "1",
|
| 732 |
"description": "Page number"
|
| 733 |
},
|
| 734 |
+
{
|
| 735 |
+
"key": "search",
|
| 736 |
+
"value": "",
|
| 737 |
+
"description": "Search keyword"
|
| 738 |
+
},
|
| 739 |
{
|
| 740 |
"key": "sortBy",
|
| 741 |
"value": "score",
|
| 742 |
+
"description": "Sort field: 'score' (default), 'duration', 'username', or 'full_name'"
|
| 743 |
},
|
| 744 |
{
|
| 745 |
+
"key": "orderBy",
|
| 746 |
"value": "desc",
|
| 747 |
"description": "Sort order: 'asc' or 'desc'"
|
| 748 |
+
},
|
| 749 |
+
{
|
| 750 |
+
"key": "order",
|
| 751 |
+
"value": "desc",
|
| 752 |
+
"disabled": true,
|
| 753 |
+
"description": "Alias for sort order (backward compatibility)"
|
| 754 |
}
|
| 755 |
],
|
| 756 |
"variable": [
|
Quzuu_Dev_Environment.postman_environment.json
CHANGED
|
@@ -79,6 +79,12 @@
|
|
| 79 |
"enabled": true,
|
| 80 |
"type": "default"
|
| 81 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
{
|
| 83 |
"key": "log_id",
|
| 84 |
"value": "",
|
|
|
|
| 79 |
"enabled": true,
|
| 80 |
"type": "default"
|
| 81 |
},
|
| 82 |
+
{
|
| 83 |
+
"key": "orderBy",
|
| 84 |
+
"value": "",
|
| 85 |
+
"enabled": true,
|
| 86 |
+
"type": "default"
|
| 87 |
+
},
|
| 88 |
{
|
| 89 |
"key": "log_id",
|
| 90 |
"value": "",
|
controllers/event_exam_controller.go
CHANGED
|
@@ -1,15 +1,15 @@
|
|
| 1 |
package controllers
|
| 2 |
|
| 3 |
-
import (
|
| 4 |
-
"strconv"
|
| 5 |
-
|
| 6 |
-
"abdanhafidz.com/go-boilerplate/models/dto"
|
| 7 |
-
entity "abdanhafidz.com/go-boilerplate/models/entity"
|
| 8 |
-
http_error "abdanhafidz.com/go-boilerplate/models/error"
|
| 9 |
-
"abdanhafidz.com/go-boilerplate/services"
|
| 10 |
-
"github.com/gin-gonic/gin"
|
| 11 |
-
"github.com/google/uuid"
|
| 12 |
-
)
|
| 13 |
|
| 14 |
type EventExamController interface {
|
| 15 |
Attempt(ctx *gin.Context)
|
|
@@ -17,6 +17,7 @@ type EventExamController interface {
|
|
| 17 |
Submit(ctx *gin.Context)
|
| 18 |
List(ctx *gin.Context)
|
| 19 |
Scoreboard(ctx *gin.Context)
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
type eventExamController struct {
|
|
@@ -60,17 +61,17 @@ func (c *eventExamController) Attempt(ctx *gin.Context) {
|
|
| 60 |
// @Success 200 {object} dto.SuccessResponse[dto.AnswerEventExamRequest]
|
| 61 |
// @Failure 400 {object} dto.ErrorResponse
|
| 62 |
// @Router /api/v1/events/{event_slug}/exam/{attempt_id}/answer_question [post]
|
| 63 |
-
func (c *eventExamController) Answer(ctx *gin.Context) {
|
| 64 |
-
eventSlug := ctx.Param("event_slug")
|
| 65 |
-
attemptId, err := uuid.Parse(ctx.Param("attempt_id"))
|
| 66 |
-
if err != nil {
|
| 67 |
-
ResponseJSON[any](ctx, gin.H{"attempt_id": ctx.Param("attempt_id")}, nil, http_error.INVALID_TOKEN)
|
| 68 |
-
return
|
| 69 |
-
}
|
| 70 |
-
req := RequestJSON[dto.AnswerEventExamRequest](ctx)
|
| 71 |
-
res, err := c.eventExamService.AnswerEventExam(ctx.Request.Context(), eventSlug, attemptId, req.QuestionId, req.Answer)
|
| 72 |
-
ResponseJSON(ctx, gin.H{"cp_grader_result": res}, req, err)
|
| 73 |
-
}
|
| 74 |
|
| 75 |
// Submit Exam Event godoc
|
| 76 |
// @Summary Submit Exam Event
|
|
@@ -84,15 +85,15 @@ func (c *eventExamController) Answer(ctx *gin.Context) {
|
|
| 84 |
// @Failure 400 {object} dto.ErrorResponse
|
| 85 |
// @Router /api/v1/events/{event_slug}/exam/{attempt_id}/submit [post]
|
| 86 |
|
| 87 |
-
func (c *eventExamController) Submit(ctx *gin.Context) {
|
| 88 |
-
attemptId, err := uuid.Parse(ctx.Param("attempt_id"))
|
| 89 |
-
if err != nil {
|
| 90 |
-
ResponseJSON[any](ctx, gin.H{"attempt_id": ctx.Param("attempt_id")}, nil, http_error.INVALID_TOKEN)
|
| 91 |
-
return
|
| 92 |
-
}
|
| 93 |
-
res, err := c.eventExamService.SubmitEventExam(ctx.Request.Context(), attemptId)
|
| 94 |
-
ResponseJSON(ctx, gin.H{}, res, err)
|
| 95 |
-
}
|
| 96 |
|
| 97 |
// List Exam by Event godoc
|
| 98 |
// @Summary List Exams by Event
|
|
@@ -111,31 +112,15 @@ func (c *eventExamController) List(ctx *gin.Context) {
|
|
| 111 |
ResponseJSON(ctx, gin.H{}, res, err)
|
| 112 |
}
|
| 113 |
|
| 114 |
-
|
| 115 |
-
// @Summary Exam Scoreboard
|
| 116 |
-
// @Description Retrieve a paginated scoreboard of participants ranked by their performance in a specific exam within an event
|
| 117 |
-
// @Tags Exam Event
|
| 118 |
-
// @Accept json
|
| 119 |
-
// @Produce json
|
| 120 |
-
// @Param event_slug path string true "Event Slug"
|
| 121 |
-
// @Param exam_slug path string true "Exam Slug"
|
| 122 |
-
// @Param limit query int false "Number of items per page" default(10)
|
| 123 |
-
// @Param page query int false "Page number" default(1)
|
| 124 |
-
// @Param sortBy query string false "Sort field: 'score' (default) or 'duration'"
|
| 125 |
-
// @Param order query string false "Sort order: 'asc' or 'desc'"
|
| 126 |
-
// @Success 200 {object} dto.SuccessResponse[[]dto.ExamScoreboardItem]
|
| 127 |
-
// @Failure 400 {object} dto.ErrorResponse
|
| 128 |
-
// @Security BearerAuth
|
| 129 |
-
// @Router /api/v1/events/{event_slug}/{exam_slug}/scoreboard [get]
|
| 130 |
-
func (c *eventExamController) Scoreboard(ctx *gin.Context) {
|
| 131 |
-
eventSlug := ctx.Param("event_slug")
|
| 132 |
-
examSlug := ctx.Param("exam_slug")
|
| 133 |
-
accountId := ParseAccountId(ctx)
|
| 134 |
-
|
| 135 |
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
| 136 |
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
| 137 |
-
|
| 138 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
if limit < 1 {
|
| 141 |
limit = 10
|
|
@@ -147,29 +132,77 @@ func (c *eventExamController) Scoreboard(ctx *gin.Context) {
|
|
| 147 |
}
|
| 148 |
|
| 149 |
offset := (page - 1) * limit
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
list, total, err := c.eventExamService.Scoreboard(ctx.Request.Context(), eventSlug, examSlug, accountId, p)
|
| 153 |
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
} else {
|
| 158 |
totalPages = int((total + int64(limit) - 1) / int64(limit))
|
| 159 |
}
|
| 160 |
|
| 161 |
if page > totalPages {
|
| 162 |
page = totalPages
|
| 163 |
-
offset = (page - 1) * limit
|
| 164 |
-
p.Offset = offset
|
| 165 |
-
list, total, err = c.eventExamService.Scoreboard(ctx.Request.Context(), eventSlug, examSlug, accountId, p)
|
| 166 |
}
|
| 167 |
|
| 168 |
-
|
| 169 |
"totalItems": total,
|
| 170 |
"totalPages": totalPages,
|
| 171 |
"currentPage": page,
|
|
|
|
| 172 |
}
|
|
|
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
ResponseJSON(ctx, meta, list, err)
|
| 175 |
}
|
|
|
|
| 1 |
package controllers
|
| 2 |
|
| 3 |
+
import (
|
| 4 |
+
"strconv"
|
| 5 |
+
|
| 6 |
+
"abdanhafidz.com/go-boilerplate/models/dto"
|
| 7 |
+
entity "abdanhafidz.com/go-boilerplate/models/entity"
|
| 8 |
+
http_error "abdanhafidz.com/go-boilerplate/models/error"
|
| 9 |
+
"abdanhafidz.com/go-boilerplate/services"
|
| 10 |
+
"github.com/gin-gonic/gin"
|
| 11 |
+
"github.com/google/uuid"
|
| 12 |
+
)
|
| 13 |
|
| 14 |
type EventExamController interface {
|
| 15 |
Attempt(ctx *gin.Context)
|
|
|
|
| 17 |
Submit(ctx *gin.Context)
|
| 18 |
List(ctx *gin.Context)
|
| 19 |
Scoreboard(ctx *gin.Context)
|
| 20 |
+
EventScoreboard(ctx *gin.Context)
|
| 21 |
}
|
| 22 |
|
| 23 |
type eventExamController struct {
|
|
|
|
| 61 |
// @Success 200 {object} dto.SuccessResponse[dto.AnswerEventExamRequest]
|
| 62 |
// @Failure 400 {object} dto.ErrorResponse
|
| 63 |
// @Router /api/v1/events/{event_slug}/exam/{attempt_id}/answer_question [post]
|
| 64 |
+
func (c *eventExamController) Answer(ctx *gin.Context) {
|
| 65 |
+
eventSlug := ctx.Param("event_slug")
|
| 66 |
+
attemptId, err := uuid.Parse(ctx.Param("attempt_id"))
|
| 67 |
+
if err != nil {
|
| 68 |
+
ResponseJSON[any](ctx, gin.H{"attempt_id": ctx.Param("attempt_id")}, nil, http_error.INVALID_TOKEN)
|
| 69 |
+
return
|
| 70 |
+
}
|
| 71 |
+
req := RequestJSON[dto.AnswerEventExamRequest](ctx)
|
| 72 |
+
res, err := c.eventExamService.AnswerEventExam(ctx.Request.Context(), eventSlug, attemptId, req.QuestionId, req.Answer)
|
| 73 |
+
ResponseJSON(ctx, gin.H{"cp_grader_result": res}, req, err)
|
| 74 |
+
}
|
| 75 |
|
| 76 |
// Submit Exam Event godoc
|
| 77 |
// @Summary Submit Exam Event
|
|
|
|
| 85 |
// @Failure 400 {object} dto.ErrorResponse
|
| 86 |
// @Router /api/v1/events/{event_slug}/exam/{attempt_id}/submit [post]
|
| 87 |
|
| 88 |
+
func (c *eventExamController) Submit(ctx *gin.Context) {
|
| 89 |
+
attemptId, err := uuid.Parse(ctx.Param("attempt_id"))
|
| 90 |
+
if err != nil {
|
| 91 |
+
ResponseJSON[any](ctx, gin.H{"attempt_id": ctx.Param("attempt_id")}, nil, http_error.INVALID_TOKEN)
|
| 92 |
+
return
|
| 93 |
+
}
|
| 94 |
+
res, err := c.eventExamService.SubmitEventExam(ctx.Request.Context(), attemptId)
|
| 95 |
+
ResponseJSON(ctx, gin.H{}, res, err)
|
| 96 |
+
}
|
| 97 |
|
| 98 |
// List Exam by Event godoc
|
| 99 |
// @Summary List Exams by Event
|
|
|
|
| 112 |
ResponseJSON(ctx, gin.H{}, res, err)
|
| 113 |
}
|
| 114 |
|
| 115 |
+
func parseScoreboardPagination(ctx *gin.Context, defaultSort string, defaultOrder string) (int, int, entity.Pagination) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
limit, _ := strconv.Atoi(ctx.DefaultQuery("limit", "10"))
|
| 117 |
page, _ := strconv.Atoi(ctx.DefaultQuery("page", "1"))
|
| 118 |
+
search := ctx.DefaultQuery("search", "")
|
| 119 |
+
sortBy := ctx.DefaultQuery("sortBy", defaultSort)
|
| 120 |
+
order := ctx.DefaultQuery("orderBy", "")
|
| 121 |
+
if order == "" {
|
| 122 |
+
order = ctx.DefaultQuery("order", defaultOrder)
|
| 123 |
+
}
|
| 124 |
|
| 125 |
if limit < 1 {
|
| 126 |
limit = 10
|
|
|
|
| 132 |
}
|
| 133 |
|
| 134 |
offset := (page - 1) * limit
|
| 135 |
+
return limit, page, entity.Pagination{Limit: limit, Offset: offset, Search: search, SortBy: sortBy, Order: order}
|
| 136 |
+
}
|
|
|
|
| 137 |
|
| 138 |
+
func buildPaginationMeta(limit int, page int, total int64) gin.H {
|
| 139 |
+
totalPages := 1
|
| 140 |
+
if total > 0 {
|
|
|
|
| 141 |
totalPages = int((total + int64(limit) - 1) / int64(limit))
|
| 142 |
}
|
| 143 |
|
| 144 |
if page > totalPages {
|
| 145 |
page = totalPages
|
|
|
|
|
|
|
|
|
|
| 146 |
}
|
| 147 |
|
| 148 |
+
return gin.H{
|
| 149 |
"totalItems": total,
|
| 150 |
"totalPages": totalPages,
|
| 151 |
"currentPage": page,
|
| 152 |
+
"limit": limit,
|
| 153 |
}
|
| 154 |
+
}
|
| 155 |
|
| 156 |
+
// Scoreboard godoc
|
| 157 |
+
// @Summary Exam Scoreboard
|
| 158 |
+
// @Description Retrieve a paginated scoreboard of participants ranked by their performance in a specific exam within an event
|
| 159 |
+
// @Tags Exam Event
|
| 160 |
+
// @Accept json
|
| 161 |
+
// @Produce json
|
| 162 |
+
// @Param event_slug path string true "Event Slug"
|
| 163 |
+
// @Param exam_slug path string true "Exam Slug"
|
| 164 |
+
// @Param limit query int false "Number of items per page" default(10)
|
| 165 |
+
// @Param page query int false "Page number" default(1)
|
| 166 |
+
// @Param search query string false "Search keyword"
|
| 167 |
+
// @Param sortBy query string false "Sort field: 'score' (default) or 'duration'"
|
| 168 |
+
// @Param orderBy query string false "Sort order: 'asc' or 'desc'"
|
| 169 |
+
// @Param order query string false "Sort order alias: 'asc' or 'desc'"
|
| 170 |
+
// @Success 200 {object} dto.SuccessResponse[[]dto.ExamScoreboardItem]
|
| 171 |
+
// @Failure 400 {object} dto.ErrorResponse
|
| 172 |
+
// @Security BearerAuth
|
| 173 |
+
// @Router /api/v1/events/{event_slug}/{exam_slug}/scoreboard [get]
|
| 174 |
+
func (c *eventExamController) Scoreboard(ctx *gin.Context) {
|
| 175 |
+
eventSlug := ctx.Param("event_slug")
|
| 176 |
+
examSlug := ctx.Param("exam_slug")
|
| 177 |
+
accountId := ParseAccountId(ctx)
|
| 178 |
+
limit, page, p := parseScoreboardPagination(ctx, "score", "desc")
|
| 179 |
+
list, total, err := c.eventExamService.Scoreboard(ctx.Request.Context(), eventSlug, examSlug, accountId, p)
|
| 180 |
+
meta := buildPaginationMeta(limit, page, total)
|
| 181 |
+
ResponseJSON(ctx, meta, list, err)
|
| 182 |
+
}
|
| 183 |
+
|
| 184 |
+
// Event Scoreboard godoc
|
| 185 |
+
// @Summary Event Scoreboard
|
| 186 |
+
// @Description Retrieve a paginated scoreboard of participants based on total score across all exams in an event
|
| 187 |
+
// @Tags Exam Event
|
| 188 |
+
// @Accept json
|
| 189 |
+
// @Produce json
|
| 190 |
+
// @Param event_slug path string true "Event Slug"
|
| 191 |
+
// @Param limit query int false "Number of items per page" default(10)
|
| 192 |
+
// @Param page query int false "Page number" default(1)
|
| 193 |
+
// @Param search query string false "Search keyword"
|
| 194 |
+
// @Param sortBy query string false "Sort field: 'total_score' (default), 'username', or 'full_name'"
|
| 195 |
+
// @Param orderBy query string false "Sort order: 'asc' or 'desc'"
|
| 196 |
+
// @Param order query string false "Sort order alias: 'asc' or 'desc'"
|
| 197 |
+
// @Success 200 {object} dto.SuccessResponse[[]dto.EventScoreboardItem]
|
| 198 |
+
// @Failure 400 {object} dto.ErrorResponse
|
| 199 |
+
// @Security BearerAuth
|
| 200 |
+
// @Router /api/v1/events/{event_slug}/scoreboard [get]
|
| 201 |
+
func (c *eventExamController) EventScoreboard(ctx *gin.Context) {
|
| 202 |
+
eventSlug := ctx.Param("event_slug")
|
| 203 |
+
accountId := ParseAccountId(ctx)
|
| 204 |
+
limit, page, p := parseScoreboardPagination(ctx, "total_score", "desc")
|
| 205 |
+
list, total, err := c.eventExamService.EventScoreboard(ctx.Request.Context(), eventSlug, accountId, p)
|
| 206 |
+
meta := buildPaginationMeta(limit, page, total)
|
| 207 |
ResponseJSON(ctx, meta, list, err)
|
| 208 |
}
|
models/dto/event_dto.go
CHANGED
|
@@ -21,23 +21,23 @@ type EventStatus struct {
|
|
| 21 |
}
|
| 22 |
|
| 23 |
type CreateEventRequest struct {
|
| 24 |
-
Title string
|
| 25 |
-
StartEvent string
|
| 26 |
-
EndEvent string
|
| 27 |
-
Overview string
|
| 28 |
-
ImgBanner string
|
| 29 |
-
EventCode string
|
| 30 |
-
IsPublic bool
|
| 31 |
Price float64 `json:"price"`
|
| 32 |
}
|
| 33 |
|
| 34 |
type UpdateEventRequest struct {
|
| 35 |
-
Title string
|
| 36 |
-
StartEvent string
|
| 37 |
-
EndEvent string
|
| 38 |
-
Overview string
|
| 39 |
-
ImgBanner string
|
| 40 |
-
IsPublic *bool
|
| 41 |
Price *float64 `json:"price"`
|
| 42 |
}
|
| 43 |
|
|
@@ -59,6 +59,12 @@ type ExamScoreboardItem struct {
|
|
| 59 |
Duration string `json:"duration"`
|
| 60 |
}
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
type AdminEventResponse struct {
|
| 63 |
entity.Events
|
| 64 |
ParticipantCount int64 `json:"participant_count"`
|
|
|
|
| 21 |
}
|
| 22 |
|
| 23 |
type CreateEventRequest struct {
|
| 24 |
+
Title string `json:"title" binding:"required"`
|
| 25 |
+
StartEvent string `json:"start_event" binding:"required"`
|
| 26 |
+
EndEvent string `json:"end_event" binding:"required"`
|
| 27 |
+
Overview string `json:"overview" binding:"required"`
|
| 28 |
+
ImgBanner string `json:"img_banner" binding:"required"`
|
| 29 |
+
EventCode string `json:"event_code"`
|
| 30 |
+
IsPublic bool `json:"is_public"`
|
| 31 |
Price float64 `json:"price"`
|
| 32 |
}
|
| 33 |
|
| 34 |
type UpdateEventRequest struct {
|
| 35 |
+
Title string `json:"title"`
|
| 36 |
+
StartEvent string `json:"start_event"`
|
| 37 |
+
EndEvent string `json:"end_event"`
|
| 38 |
+
Overview string `json:"overview"`
|
| 39 |
+
ImgBanner string `json:"img_banner"`
|
| 40 |
+
IsPublic *bool `json:"is_public"`
|
| 41 |
Price *float64 `json:"price"`
|
| 42 |
}
|
| 43 |
|
|
|
|
| 59 |
Duration string `json:"duration"`
|
| 60 |
}
|
| 61 |
|
| 62 |
+
type EventScoreboardItem struct {
|
| 63 |
+
Username string `json:"username"`
|
| 64 |
+
FullName string `json:"full_name,omitempty"`
|
| 65 |
+
TotalScore float32 `json:"total_score"`
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
type AdminEventResponse struct {
|
| 69 |
entity.Events
|
| 70 |
ParticipantCount int64 `json:"participant_count"`
|
models/entity/entity.go
CHANGED
|
@@ -287,6 +287,13 @@ type ExamScoreboardEntry struct {
|
|
| 287 |
DurationSeconds int64
|
| 288 |
}
|
| 289 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
type EventExamAttempt struct {
|
| 291 |
Id uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id_attempt"`
|
| 292 |
AccountId uuid.UUID `json:"id_account,omitempty"`
|
|
|
|
| 287 |
DurationSeconds int64
|
| 288 |
}
|
| 289 |
|
| 290 |
+
type EventScoreboardEntry struct {
|
| 291 |
+
AccountId uuid.UUID
|
| 292 |
+
Username string
|
| 293 |
+
FullName string
|
| 294 |
+
TotalScore float32
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
type EventExamAttempt struct {
|
| 298 |
Id uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id_attempt"`
|
| 299 |
AccountId uuid.UUID `json:"id_account,omitempty"`
|
repositories/event_exam_attempt_repository.go
CHANGED
|
@@ -2,6 +2,7 @@ package repositories
|
|
| 2 |
|
| 3 |
import (
|
| 4 |
"context"
|
|
|
|
| 5 |
|
| 6 |
entity "abdanhafidz.com/go-boilerplate/models/entity"
|
| 7 |
"github.com/google/uuid"
|
|
@@ -14,6 +15,7 @@ type EventExamAttemptRepository interface {
|
|
| 14 |
GetByEventExam(ctx context.Context, eventId uuid.UUID, examId uuid.UUID, accountId uuid.UUID) (entity.EventExamAttempt, error)
|
| 15 |
Update(ctx context.Context, a *entity.EventExamAttempt) error
|
| 16 |
GetScoreboard(ctx context.Context, eventId uuid.UUID, examId uuid.UUID, p entity.Pagination) ([]entity.ExamScoreboardEntry, int64, error)
|
|
|
|
| 17 |
}
|
| 18 |
|
| 19 |
type eventExamAttemptRepository struct{ db *gorm.DB }
|
|
@@ -76,6 +78,12 @@ func (r *eventExamAttemptRepository) GetScoreboard(ctx context.Context, eventId
|
|
| 76 |
case "duration":
|
| 77 |
col = "duration_seconds"
|
| 78 |
ord = "ASC"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
}
|
| 80 |
|
| 81 |
if p.Order == "asc" {
|
|
@@ -84,6 +92,16 @@ func (r *eventExamAttemptRepository) GetScoreboard(ctx context.Context, eventId
|
|
| 84 |
ord = "DESC"
|
| 85 |
}
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
rawSQL := `
|
| 88 |
SELECT
|
| 89 |
a.id AS account_id,
|
|
@@ -100,13 +118,14 @@ func (r *eventExamAttemptRepository) GetScoreboard(ctx context.Context, eventId
|
|
| 100 |
JOIN exam_event_attempt eea ON eea.id = r.attempt_id
|
| 101 |
JOIN account a ON a.id = eea.account_id
|
| 102 |
LEFT JOIN account_details ad ON ad.account_id = a.id
|
| 103 |
-
|
| 104 |
ORDER BY ` + col + ` ` + ord + `
|
| 105 |
LIMIT ? OFFSET ?
|
| 106 |
`
|
| 107 |
|
| 108 |
var rows []rawRow
|
| 109 |
-
|
|
|
|
| 110 |
return nil, 0, err
|
| 111 |
}
|
| 112 |
|
|
@@ -129,3 +148,85 @@ func (r *eventExamAttemptRepository) GetScoreboard(ctx context.Context, eventId
|
|
| 129 |
|
| 130 |
return entries, total, nil
|
| 131 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import (
|
| 4 |
"context"
|
| 5 |
+
"strings"
|
| 6 |
|
| 7 |
entity "abdanhafidz.com/go-boilerplate/models/entity"
|
| 8 |
"github.com/google/uuid"
|
|
|
|
| 15 |
GetByEventExam(ctx context.Context, eventId uuid.UUID, examId uuid.UUID, accountId uuid.UUID) (entity.EventExamAttempt, error)
|
| 16 |
Update(ctx context.Context, a *entity.EventExamAttempt) error
|
| 17 |
GetScoreboard(ctx context.Context, eventId uuid.UUID, examId uuid.UUID, p entity.Pagination) ([]entity.ExamScoreboardEntry, int64, error)
|
| 18 |
+
GetEventScoreboard(ctx context.Context, eventId uuid.UUID, p entity.Pagination) ([]entity.EventScoreboardEntry, int64, error)
|
| 19 |
}
|
| 20 |
|
| 21 |
type eventExamAttemptRepository struct{ db *gorm.DB }
|
|
|
|
| 78 |
case "duration":
|
| 79 |
col = "duration_seconds"
|
| 80 |
ord = "ASC"
|
| 81 |
+
case "username":
|
| 82 |
+
col = "a.username"
|
| 83 |
+
ord = "ASC"
|
| 84 |
+
case "full_name":
|
| 85 |
+
col = "COALESCE(ad.full_name, '')"
|
| 86 |
+
ord = "ASC"
|
| 87 |
}
|
| 88 |
|
| 89 |
if p.Order == "asc" {
|
|
|
|
| 92 |
ord = "DESC"
|
| 93 |
}
|
| 94 |
|
| 95 |
+
whereSQL := "WHERE eea.event_id = ? AND eea.exam_id = ?"
|
| 96 |
+
args := []any{eventId, examId}
|
| 97 |
+
|
| 98 |
+
search := strings.TrimSpace(p.Search)
|
| 99 |
+
if search != "" {
|
| 100 |
+
like := "%" + search + "%"
|
| 101 |
+
whereSQL += " AND (a.username ILIKE ? OR COALESCE(ad.full_name, '') ILIKE ?)"
|
| 102 |
+
args = append(args, like, like)
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
rawSQL := `
|
| 106 |
SELECT
|
| 107 |
a.id AS account_id,
|
|
|
|
| 118 |
JOIN exam_event_attempt eea ON eea.id = r.attempt_id
|
| 119 |
JOIN account a ON a.id = eea.account_id
|
| 120 |
LEFT JOIN account_details ad ON ad.account_id = a.id
|
| 121 |
+
` + whereSQL + `
|
| 122 |
ORDER BY ` + col + ` ` + ord + `
|
| 123 |
LIMIT ? OFFSET ?
|
| 124 |
`
|
| 125 |
|
| 126 |
var rows []rawRow
|
| 127 |
+
args = append(args, p.Limit, p.Offset)
|
| 128 |
+
if err := r.db.WithContext(ctx).Raw(rawSQL, args...).Scan(&rows).Error; err != nil {
|
| 129 |
return nil, 0, err
|
| 130 |
}
|
| 131 |
|
|
|
|
| 148 |
|
| 149 |
return entries, total, nil
|
| 150 |
}
|
| 151 |
+
|
| 152 |
+
func (r *eventExamAttemptRepository) GetEventScoreboard(ctx context.Context, eventId uuid.UUID, p entity.Pagination) ([]entity.EventScoreboardEntry, int64, error) {
|
| 153 |
+
type rawRow struct {
|
| 154 |
+
AccountId string
|
| 155 |
+
Username string
|
| 156 |
+
FullName string
|
| 157 |
+
TotalScore float32
|
| 158 |
+
TotalCount int64
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
col := "total_score"
|
| 162 |
+
ord := "DESC"
|
| 163 |
+
|
| 164 |
+
switch p.SortBy {
|
| 165 |
+
case "username":
|
| 166 |
+
col = "a.username"
|
| 167 |
+
ord = "ASC"
|
| 168 |
+
case "full_name":
|
| 169 |
+
col = "COALESCE(ad.full_name, '')"
|
| 170 |
+
ord = "ASC"
|
| 171 |
+
case "total_score", "score":
|
| 172 |
+
col = "total_score"
|
| 173 |
+
ord = "DESC"
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
if p.Order == "asc" {
|
| 177 |
+
ord = "ASC"
|
| 178 |
+
} else if p.Order == "desc" {
|
| 179 |
+
ord = "DESC"
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
whereSQL := "WHERE eea.event_id = ?"
|
| 183 |
+
args := []any{eventId}
|
| 184 |
+
|
| 185 |
+
search := strings.TrimSpace(p.Search)
|
| 186 |
+
if search != "" {
|
| 187 |
+
like := "%" + search + "%"
|
| 188 |
+
whereSQL += " AND (a.username ILIKE ? OR COALESCE(ad.full_name, '') ILIKE ?)"
|
| 189 |
+
args = append(args, like, like)
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
rawSQL := `
|
| 193 |
+
SELECT
|
| 194 |
+
a.id AS account_id,
|
| 195 |
+
a.username AS username,
|
| 196 |
+
COALESCE(ad.full_name, '') AS full_name,
|
| 197 |
+
SUM(r.final_score) AS total_score,
|
| 198 |
+
COUNT(*) OVER() AS total_count
|
| 199 |
+
FROM result r
|
| 200 |
+
JOIN exam_event_attempt eea ON eea.id = r.attempt_id
|
| 201 |
+
JOIN account a ON a.id = eea.account_id
|
| 202 |
+
LEFT JOIN account_details ad ON ad.account_id = a.id
|
| 203 |
+
` + whereSQL + `
|
| 204 |
+
GROUP BY a.id, a.username, ad.full_name
|
| 205 |
+
ORDER BY ` + col + ` ` + ord + `
|
| 206 |
+
LIMIT ? OFFSET ?
|
| 207 |
+
`
|
| 208 |
+
|
| 209 |
+
var rows []rawRow
|
| 210 |
+
args = append(args, p.Limit, p.Offset)
|
| 211 |
+
if err := r.db.WithContext(ctx).Raw(rawSQL, args...).Scan(&rows).Error; err != nil {
|
| 212 |
+
return nil, 0, err
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
var total int64
|
| 216 |
+
if len(rows) > 0 {
|
| 217 |
+
total = rows[0].TotalCount
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
entries := make([]entity.EventScoreboardEntry, 0, len(rows))
|
| 221 |
+
for _, row := range rows {
|
| 222 |
+
id, _ := uuid.Parse(row.AccountId)
|
| 223 |
+
entries = append(entries, entity.EventScoreboardEntry{
|
| 224 |
+
AccountId: id,
|
| 225 |
+
Username: row.Username,
|
| 226 |
+
FullName: row.FullName,
|
| 227 |
+
TotalScore: row.TotalScore,
|
| 228 |
+
})
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
return entries, total, nil
|
| 232 |
+
}
|
router/event_exam_proctoring_router.go
CHANGED
|
@@ -10,7 +10,7 @@ func EventExamProctoringRouter(router *gin.Engine, middleware provider.Middlewar
|
|
| 10 |
auth := middleware.ProvideAuthenticationMiddleware()
|
| 11 |
|
| 12 |
// Group under api/v1/events to match existing event structure
|
| 13 |
-
routerGroup := router.Group("api/v1/
|
| 14 |
{
|
| 15 |
// :event_slug and :exam_slug are kept for context/consistency with other routes,
|
| 16 |
// even if not strictly used for lookup (IDs are in body/query).
|
|
|
|
| 10 |
auth := middleware.ProvideAuthenticationMiddleware()
|
| 11 |
|
| 12 |
// Group under api/v1/events to match existing event structure
|
| 13 |
+
routerGroup := router.Group("api/v1/events")
|
| 14 |
{
|
| 15 |
// :event_slug and :exam_slug are kept for context/consistency with other routes,
|
| 16 |
// even if not strictly used for lookup (IDs are in body/query).
|
router/event_exam_router.go
CHANGED
|
@@ -14,6 +14,7 @@ func EventExamRouter(router *gin.Engine, middleware provider.MiddlewareProvider,
|
|
| 14 |
routerGroup.GET("/:event_slug/exam/:exam_slug/attempt", auth.VerifyAccount, eventExamController.Attempt)
|
| 15 |
routerGroup.POST("/:event_slug/exam/:attempt_id/answer_question", auth.VerifyAccount, eventExamController.Answer)
|
| 16 |
routerGroup.POST("/:event_slug/exam/:attempt_id/submit", auth.VerifyAccount, eventExamController.Submit)
|
|
|
|
| 17 |
routerGroup.GET("/:event_slug/:exam_slug/scoreboard", auth.VerifyAccount, eventExamController.Scoreboard)
|
| 18 |
}
|
| 19 |
}
|
|
|
|
| 14 |
routerGroup.GET("/:event_slug/exam/:exam_slug/attempt", auth.VerifyAccount, eventExamController.Attempt)
|
| 15 |
routerGroup.POST("/:event_slug/exam/:attempt_id/answer_question", auth.VerifyAccount, eventExamController.Answer)
|
| 16 |
routerGroup.POST("/:event_slug/exam/:attempt_id/submit", auth.VerifyAccount, eventExamController.Submit)
|
| 17 |
+
routerGroup.GET("/:event_slug/scoreboard", auth.VerifyAccount, eventExamController.EventScoreboard)
|
| 18 |
routerGroup.GET("/:event_slug/:exam_slug/scoreboard", auth.VerifyAccount, eventExamController.Scoreboard)
|
| 19 |
}
|
| 20 |
}
|
services/event_exam_service.go
CHANGED
|
@@ -26,6 +26,7 @@ type EventExamService interface {
|
|
| 26 |
SubmitEventExam(ctx context.Context, attemptId uuid.UUID) (result entity.Result, err error)
|
| 27 |
AnswerEventExam(ctx context.Context, eventSlug string, attemptId uuid.UUID, questionId uuid.UUID, answer []string) (entity.CPQuestionVerdict, error)
|
| 28 |
Scoreboard(ctx context.Context, eventSlug string, examSlug string, accountId uuid.UUID, p entity.Pagination) ([]dto.ExamScoreboardItem, int64, error)
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
type evaluator func(answer []string) (float32, entity.CPQuestionVerdict)
|
|
@@ -266,8 +267,6 @@ func (s *eventExamService) AttemptEventExam(ctx context.Context, eventSlug strin
|
|
| 266 |
}
|
| 267 |
attemptStatus, eventExamAttempt, err := s.GetEventExamAttempt(ctx, eventSlug, examSlug, accountId)
|
| 268 |
|
| 269 |
-
|
| 270 |
-
|
| 271 |
if err != nil {
|
| 272 |
return entity.EventExamAttempt{}, err
|
| 273 |
}
|
|
@@ -507,3 +506,26 @@ func (s *eventExamService) Scoreboard(ctx context.Context, eventSlug string, exa
|
|
| 507 |
|
| 508 |
return items, total, nil
|
| 509 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
SubmitEventExam(ctx context.Context, attemptId uuid.UUID) (result entity.Result, err error)
|
| 27 |
AnswerEventExam(ctx context.Context, eventSlug string, attemptId uuid.UUID, questionId uuid.UUID, answer []string) (entity.CPQuestionVerdict, error)
|
| 28 |
Scoreboard(ctx context.Context, eventSlug string, examSlug string, accountId uuid.UUID, p entity.Pagination) ([]dto.ExamScoreboardItem, int64, error)
|
| 29 |
+
EventScoreboard(ctx context.Context, eventSlug string, accountId uuid.UUID, p entity.Pagination) ([]dto.EventScoreboardItem, int64, error)
|
| 30 |
}
|
| 31 |
|
| 32 |
type evaluator func(answer []string) (float32, entity.CPQuestionVerdict)
|
|
|
|
| 267 |
}
|
| 268 |
attemptStatus, eventExamAttempt, err := s.GetEventExamAttempt(ctx, eventSlug, examSlug, accountId)
|
| 269 |
|
|
|
|
|
|
|
| 270 |
if err != nil {
|
| 271 |
return entity.EventExamAttempt{}, err
|
| 272 |
}
|
|
|
|
| 506 |
|
| 507 |
return items, total, nil
|
| 508 |
}
|
| 509 |
+
|
| 510 |
+
func (s *eventExamService) EventScoreboard(ctx context.Context, eventSlug string, accountId uuid.UUID, p entity.Pagination) ([]dto.EventScoreboardItem, int64, error) {
|
| 511 |
+
ev, err := s.eventService.DetailBySlug(ctx, eventSlug, accountId)
|
| 512 |
+
if err != nil {
|
| 513 |
+
return nil, 0, err
|
| 514 |
+
}
|
| 515 |
+
|
| 516 |
+
entries, total, err := s.eventExamAttemptRepo.GetEventScoreboard(ctx, ev.Data.Id, p)
|
| 517 |
+
if err != nil {
|
| 518 |
+
return nil, 0, err
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
items := make([]dto.EventScoreboardItem, 0, len(entries))
|
| 522 |
+
for _, entry := range entries {
|
| 523 |
+
items = append(items, dto.EventScoreboardItem{
|
| 524 |
+
Username: entry.Username,
|
| 525 |
+
FullName: entry.FullName,
|
| 526 |
+
TotalScore: entry.TotalScore,
|
| 527 |
+
})
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
return items, total, nil
|
| 531 |
+
}
|