lifedebugger commited on
Commit
d0e1b44
·
1 Parent(s): 1b4f42f

Deploy files from GitHub repository

Browse files
.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 = smtp.mailgun.org
18
- SMTP_PORT = 587
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&order=desc",
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 'duration'"
671
  },
672
  {
673
- "key": "order",
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
- // Scoreboard godoc
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
- sortBy := ctx.DefaultQuery("sortBy", "score")
138
- order := ctx.DefaultQuery("order", "desc")
 
 
 
 
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
- p := entity.Pagination{Limit: limit, Offset: offset, SortBy: sortBy, Order: order}
151
-
152
- list, total, err := c.eventExamService.Scoreboard(ctx.Request.Context(), eventSlug, examSlug, accountId, p)
153
 
154
- var totalPages int
155
- if total == 0 {
156
- totalPages = 1
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
- meta := gin.H{
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 `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,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
- WHERE eea.event_id = ? AND eea.exam_id = ?
104
  ORDER BY ` + col + ` ` + ord + `
105
  LIMIT ? OFFSET ?
106
  `
107
 
108
  var rows []rawRow
109
- if err := r.db.WithContext(ctx).Raw(rawSQL, eventId, examId, p.Limit, p.Offset).Scan(&rows).Error; err != nil {
 
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/proctoring")
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
+ }