lifedebugger commited on
Commit
6427640
·
1 Parent(s): 709249d

Deploy files from GitHub repository

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. controller/quiz/result_quiz_controller.go +24 -0
  2. controller/quiz/submit_quiz_controller.go +2 -1
  3. models/database_orm_model.go +3 -2
  4. repositories/academy_repository.go +10 -0
  5. repositories/quiz_repository.go +12 -0
  6. router/quiz_route.go +2 -0
  7. services/academy_quiz_service.go +64 -2
  8. services/academy_service.go +13 -0
  9. space/.env.example +26 -9
  10. space/space/pkg/validation/custom_rules.go +1 -1
  11. space/space/space/services/cv_service.go +11 -13
  12. space/space/space/space/controller/cv/cv_controller.go +17 -2
  13. space/space/space/space/models/field_counter.go +163 -0
  14. space/space/space/space/models/request_model.go +12 -0
  15. space/space/space/space/router/cv_route.go +2 -0
  16. space/space/space/space/services/cv_service.go +115 -0
  17. space/space/space/space/space/models/database_orm_model.go +47 -9
  18. space/space/space/space/space/repositories/quiz_repository.go +5 -2
  19. space/space/space/space/space/services/academy_quiz_service.go +1 -1
  20. space/space/space/space/space/space/pkg/validation/custom_rules.go +42 -0
  21. space/space/space/space/space/space/pkg/validation/validation.go +1 -0
  22. space/space/space/space/space/space/space/Makefile +9 -1
  23. space/space/space/space/space/space/space/config/config.go +3 -0
  24. space/space/space/space/space/space/space/controller/cv/cv_controller.go +20 -0
  25. space/space/space/space/space/space/space/docker-compose.yml +4 -7
  26. space/space/space/space/space/space/space/go.mod +1 -1
  27. space/space/space/space/space/space/space/main.go +8 -4
  28. space/space/space/space/space/space/space/models/exception_model.go +3 -1
  29. space/space/space/space/space/space/space/models/request_model.go +13 -1
  30. space/space/space/space/space/space/space/pkg/storage/local.go +67 -0
  31. space/space/space/space/space/space/space/pkg/storage/storage.go +71 -0
  32. space/space/space/space/space/space/space/response/api_response_v2.go +1 -1
  33. space/space/space/space/space/space/space/response/validation.go +1 -1
  34. space/space/space/space/space/space/space/router/cv_route.go +1 -0
  35. space/space/space/space/space/space/space/router/router.go +1 -0
  36. space/space/space/space/space/space/space/router/storage_route.go +24 -0
  37. space/space/space/space/space/space/space/services/cv_service.go +468 -385
  38. space/space/space/space/space/space/space/services/email_verification_service.go +78 -78
  39. space/space/space/space/space/space/space/services/forgot_password_service.go +1 -1
  40. space/space/space/space/space/space/space/space/.gitignore +1 -0
  41. space/space/space/space/space/space/space/space/pkg/validation/custom_rules.go +162 -0
  42. space/space/space/space/space/space/space/space/pkg/validation/validation.go +164 -0
  43. space/space/space/space/space/space/space/space/pkg/worker/distributor.go +23 -0
  44. space/space/space/space/space/space/space/space/pkg/worker/logger.go +37 -0
  45. space/space/space/space/space/space/space/space/pkg/worker/processor.go +66 -0
  46. space/space/space/space/space/space/space/space/pkg/worker/redis_distributor.go +16 -0
  47. space/space/space/space/space/space/space/space/pkg/worker/task_send_forgot_password_email.go +75 -0
  48. space/space/space/space/space/space/space/space/pkg/worker/task_send_verify_email.go +75 -0
  49. space/space/space/space/space/space/space/space/space/main.go +5 -0
  50. space/space/space/space/space/space/space/space/space/models/exception_model.go +18 -15
controller/quiz/result_quiz_controller.go ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package controller
2
+
3
+ import (
4
+ "strconv"
5
+
6
+ "api.qobiltu.id/controller"
7
+ "api.qobiltu.id/models"
8
+ "api.qobiltu.id/services"
9
+ "github.com/gin-gonic/gin"
10
+ )
11
+
12
+ func Result(c *gin.Context) {
13
+ quizResult := services.QuizResultService{}
14
+ quizResultController := controller.Controller[any, models.QuizAttempt, []models.QuizResultResponse]{
15
+ Service: &quizResult.Service,
16
+ }
17
+ quizResultController.HeaderParse(c, func() {
18
+ academyId, _ := strconv.Atoi(c.Param("attempt_id"))
19
+ quizResult.Constructor.AccountID = uint(quizResultController.AccountData.UserID)
20
+ quizResult.Constructor.ID = uint(academyId) | 0
21
+ quizResult.Retrieve()
22
+ quizResultController.Response(c)
23
+ })
24
+ }
controller/quiz/submit_quiz_controller.go CHANGED
@@ -17,7 +17,8 @@ func Submit(c *gin.Context) {
17
  submitQuizController.HeaderParse(c, func() {
18
  quizId, _ := strconv.Atoi(c.Param("attempt_id"))
19
  submitQuizController.Service.Constructor.ID = uint(quizId)
20
- submitQuiz.Create(submitQuizController.AccountData.UserID)
 
21
  submitQuizController.Response(c)
22
  })
23
  }
 
17
  submitQuizController.HeaderParse(c, func() {
18
  quizId, _ := strconv.Atoi(c.Param("attempt_id"))
19
  submitQuizController.Service.Constructor.ID = uint(quizId)
20
+ submitQuizController.Service.Constructor.AccountID = uint(submitQuizController.AccountData.UserID)
21
+ submitQuiz.Create()
22
  submitQuizController.Response(c)
23
  })
24
  }
models/database_orm_model.go CHANGED
@@ -152,7 +152,8 @@ type Question struct {
152
  QuizID uint `json:"quiz_id"`
153
  Content string `json:"content"`
154
  Order int `json:"order"`
155
- CorrectAnswer uint `json:"-"`
 
156
  }
157
  type Quiz struct {
158
  ID uint `gorm:"primaryKey" json:"id"`
@@ -160,7 +161,7 @@ type Quiz struct {
160
  Slug string `json:"slug" gorm:"uniqueIndex" `
161
  Title string `json:"title"`
162
  Description string `json:"description"`
163
- TotalQuestions string `json:"total_questions"`
164
  AttemptLimit int `json:"attempt_limit"`
165
  TimeLimit int `json:"time_limit"`
166
  MinScore int `json:"min_score"`
 
152
  QuizID uint `json:"quiz_id"`
153
  Content string `json:"content"`
154
  Order int `json:"order"`
155
+ CorrectAnswer uint `json:"corrent_answer"`
156
+ Review string `json:"reviews"`
157
  }
158
  type Quiz struct {
159
  ID uint `gorm:"primaryKey" json:"id"`
 
161
  Slug string `json:"slug" gorm:"uniqueIndex" `
162
  Title string `json:"title"`
163
  Description string `json:"description"`
164
+ TotalQuestions int `json:"total_questions"`
165
  AttemptLimit int `json:"attempt_limit"`
166
  TimeLimit int `json:"time_limit"`
167
  MinScore int `json:"min_score"`
repositories/academy_repository.go CHANGED
@@ -93,3 +93,13 @@ func GetAcademyByID(id uint) Repository[models.Academy, models.Academy] {
93
  )
94
  return *repo
95
  }
 
 
 
 
 
 
 
 
 
 
 
93
  )
94
  return *repo
95
  }
96
+
97
+ func UpdateAcademyMaterialCompletedById(id_material uint) Repository[models.AcademyMaterial, models.AcademyMaterial] {
98
+ repo := Construct[models.AcademyMaterial, models.AcademyMaterial](
99
+ models.AcademyMaterial{ID: id_material, IsCompleted: true},
100
+ )
101
+ Update(repo)
102
+ return *repo
103
+ }
104
+
105
+ // func UpdateAcademyProgressById(id_academy uint)
repositories/quiz_repository.go CHANGED
@@ -63,6 +63,18 @@ func GetAttemptByIdandUserId(attemptId uint, userId uint) Repository[models.Quiz
63
  return *repo
64
  }
65
 
 
 
 
 
 
 
 
 
 
 
 
 
66
  func CreateAttempt(quizAttempt models.QuizAttempt) Repository[models.QuizAttempt, models.QuizAttempt] {
67
  repo := Construct[models.QuizAttempt, models.QuizAttempt](
68
  quizAttempt,
 
63
  return *repo
64
  }
65
 
66
+ func GetAttemptByUserId(userId uint) Repository[models.QuizAttempt, []models.QuizAttempt] {
67
+ repo := Construct[models.QuizAttempt, []models.QuizAttempt](
68
+ models.QuizAttempt{
69
+ AccountID: userId,
70
+ },
71
+ )
72
+ repo.Transactions(
73
+ WhereGivenConstructor[models.QuizAttempt, []models.QuizAttempt],
74
+ Find[models.QuizAttempt, []models.QuizAttempt],
75
+ )
76
+ return *repo
77
+ }
78
  func CreateAttempt(quizAttempt models.QuizAttempt) Repository[models.QuizAttempt, models.QuizAttempt] {
79
  repo := Construct[models.QuizAttempt, models.QuizAttempt](
80
  quizAttempt,
router/quiz_route.go CHANGED
@@ -13,6 +13,8 @@ func QuizRoute(router *gin.Engine) {
13
  routerGroup.POST("/:academy_id/:quiz_id/attempt", middleware.AuthUser, QuizController.Attempt)
14
  routerGroup.GET("/:academy_id/:quiz_id/question", middleware.AuthUser, QuizController.Question)
15
  routerGroup.PUT("/:academy_id/:quiz_id/choose-answer", middleware.AuthUser, QuizController.Answer)
 
 
16
  routerGroup.POST("/submit-attempt/:attempt_id", middleware.AuthUser, QuizController.Submit)
17
  }
18
  }
 
13
  routerGroup.POST("/:academy_id/:quiz_id/attempt", middleware.AuthUser, QuizController.Attempt)
14
  routerGroup.GET("/:academy_id/:quiz_id/question", middleware.AuthUser, QuizController.Question)
15
  routerGroup.PUT("/:academy_id/:quiz_id/choose-answer", middleware.AuthUser, QuizController.Answer)
16
+ routerGroup.GET("result/:attempt_id", middleware.AuthUser, QuizController.Result)
17
+ routerGroup.GET("result/", middleware.AuthUser, QuizController.Result)
18
  routerGroup.POST("/submit-attempt/:attempt_id", middleware.AuthUser, QuizController.Submit)
19
  }
20
  }
services/academy_quiz_service.go CHANGED
@@ -20,6 +20,10 @@ type QuizListService struct {
20
  Service[models.Academy, []models.Quiz]
21
  }
22
 
 
 
 
 
23
  func AuthorizeQuizwithAcademy(s *AttemptQuizService, next func()) {
24
  academyRepo := repositories.GetAcademyByID(s.Constructor.AcademyID)
25
  s.Error = academyRepo.RowsError
@@ -130,8 +134,8 @@ func (s *AttemptQuizService) Create(userID uint) {
130
  })
131
  }
132
 
133
- func (s *SubmitQuizService) Create(userID uint) {
134
- quizAttemptRepo := repositories.GetAttemptByIdandUserId(s.Constructor.ID, userID)
135
  if quizAttemptRepo.NoRecord {
136
  s.Exception.DataNotFound = true
137
  s.Exception.Message = "There is no quiz attempt with given user!"
@@ -139,6 +143,10 @@ func (s *SubmitQuizService) Create(userID uint) {
139
  }
140
  s.Error = errors.Join(s.Error, quizAttemptRepo.RowsError)
141
  countScoreRepo := repositories.CountUserAttemptScore(s.Constructor.ID)
 
 
 
 
142
  s.Error = errors.Join(s.Error, countScoreRepo.RowsError)
143
 
144
  if quizAttemptRepo.Result.FinishedAt == nil {
@@ -168,3 +176,57 @@ func (s *QuizListService) Retrieve() {
168
  s.Result = quizRepo.Result
169
  return
170
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  Service[models.Academy, []models.Quiz]
21
  }
22
 
23
+ type QuizResultService struct {
24
+ Service[models.QuizAttempt, []models.QuizResultResponse]
25
+ }
26
+
27
  func AuthorizeQuizwithAcademy(s *AttemptQuizService, next func()) {
28
  academyRepo := repositories.GetAcademyByID(s.Constructor.AcademyID)
29
  s.Error = academyRepo.RowsError
 
134
  })
135
  }
136
 
137
+ func (s *SubmitQuizService) Create() {
138
+ quizAttemptRepo := repositories.GetAttemptByIdandUserId(s.Constructor.ID, s.Constructor.AccountID)
139
  if quizAttemptRepo.NoRecord {
140
  s.Exception.DataNotFound = true
141
  s.Exception.Message = "There is no quiz attempt with given user!"
 
143
  }
144
  s.Error = errors.Join(s.Error, quizAttemptRepo.RowsError)
145
  countScoreRepo := repositories.CountUserAttemptScore(s.Constructor.ID)
146
+ if countScoreRepo.NoRecord {
147
+ s.Exception.DataNotFound = true
148
+ s.Exception.Message = "There is no quiz attempt with given user!"
149
+ }
150
  s.Error = errors.Join(s.Error, countScoreRepo.RowsError)
151
 
152
  if quizAttemptRepo.Result.FinishedAt == nil {
 
176
  s.Result = quizRepo.Result
177
  return
178
  }
179
+
180
+ func (s *QuizResultService) Retrieve() {
181
+ if s.Constructor.ID != 0 {
182
+ attemptRepo := repositories.GetAttemptByIdandUserId(s.Constructor.ID, s.Constructor.AccountID)
183
+ s.Error = attemptRepo.RowsError
184
+ if attemptRepo.NoRecord {
185
+ s.Exception.DataNotFound = true
186
+ s.Exception.Message = "There is no attempt with given user id"
187
+ return
188
+ }
189
+ if attemptRepo.Result.FinishedAt == nil {
190
+ s.Exception.Forbidden = true
191
+ s.Exception.Message = "You have to submit the exam first to see the result!"
192
+ return
193
+ }
194
+ countScoreRepo := repositories.CountUserAttemptScore(s.Constructor.ID)
195
+ s.Error = errors.Join(s.Error, countScoreRepo.RowsError)
196
+ if countScoreRepo.NoRecord {
197
+ s.Exception.DataNotFound = true
198
+ s.Exception.Message = "There is no quiz attempt with given user!"
199
+ }
200
+ s.Result = []models.QuizResultResponse{
201
+ models.QuizResultResponse{
202
+ QuizAttempt: attemptRepo.Result,
203
+ Result: countScoreRepo.Result,
204
+ },
205
+ }
206
+ return
207
+ } else {
208
+ allAttemptsRepo := repositories.GetAttemptByUserId(s.Constructor.AccountID)
209
+ if allAttemptsRepo.NoRecord {
210
+ s.Exception.DataNotFound = true
211
+ s.Exception.Message = "There is no attempt with given user id!"
212
+ return
213
+ }
214
+ s.Error = allAttemptsRepo.RowsError
215
+ var arrResult []models.QuizResultResponse
216
+ for _, attempt := range allAttemptsRepo.Result {
217
+ if attempt.FinishedAt != nil {
218
+ countScoreRepo := repositories.CountUserAttemptScore(attempt.ID)
219
+ s.Error = errors.Join(s.Error, countScoreRepo.RowsError)
220
+ if countScoreRepo.NoRecord {
221
+ continue
222
+ }
223
+ arrResult = append(arrResult, models.QuizResultResponse{
224
+ QuizAttempt: attempt,
225
+ Result: countScoreRepo.Result,
226
+ })
227
+ }
228
+ }
229
+ s.Result = arrResult
230
+ return
231
+ }
232
+ }
services/academy_service.go CHANGED
@@ -25,6 +25,10 @@ type AcademyContentService struct {
25
  Service[models.AcademyMaterial, models.AcademyContent]
26
  }
27
 
 
 
 
 
28
  func castAcademyMaterials(academyId uint) []models.AcademyMaterialResponse {
29
  var ArrMaterials []models.AcademyMaterialResponse
30
  academyMaterialsRepo := repositories.GetAllAcademyMaterialsByAcademyID(academyId)
@@ -135,3 +139,12 @@ func (s *AcademyContentService) Retrieve() {
135
  }
136
  s.Result = academyContentRepo.Result
137
  }
 
 
 
 
 
 
 
 
 
 
25
  Service[models.AcademyMaterial, models.AcademyContent]
26
  }
27
 
28
+ type AcademyMarkReadService struct {
29
+ Service[models.AcademyMaterial, models.Academy]
30
+ }
31
+
32
  func castAcademyMaterials(academyId uint) []models.AcademyMaterialResponse {
33
  var ArrMaterials []models.AcademyMaterialResponse
34
  academyMaterialsRepo := repositories.GetAllAcademyMaterialsByAcademyID(academyId)
 
139
  }
140
  s.Result = academyContentRepo.Result
141
  }
142
+
143
+ func (s *AcademyMarkReadService) Update() {
144
+ markReadRepo := repositories.UpdateAcademyMaterialCompletedById(s.Constructor.ID)
145
+ if markReadRepo.NoRecord {
146
+ s.Exception.DataNotFound = true
147
+ s.Error = markReadRepo.RowsError
148
+ return
149
+ }
150
+ }
space/.env.example CHANGED
@@ -1,9 +1,26 @@
1
- DB_HOST =
2
- DB_USER =
3
- DB_PASSWORD =
4
- DB_PORT =
5
- DB_NAME =
6
- SALT =
7
- HOST_ADDRESS =
8
- HOST_PORT =
9
- LOG_PATH = logs
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ APP_URL=https://api.qobiltu.id
2
+ ENV=production
3
+
4
+ DB_HOST=db
5
+ DB_USER=qobiltu
6
+ DB_PASSWORD=
7
+ DB_PORT=
8
+ DB_NAME=
9
+ SALT=
10
+ HOST_ADDRESS=
11
+ HOST_PORT=
12
+ LOG_PATH=logs
13
+
14
+ SMTP_SENDER_EMAIL=
15
+ SMTP_SENDER_PASSWORD=
16
+ SMTP_HOST=
17
+ SMTP_PORT=
18
+ EMAIL_VERIFICATION_DURATION=
19
+
20
+ REDIS_HOST=redis
21
+ REDIS_PORT=6379
22
+ REDIS_PASSWORD=
23
+ REDIS_DB=0
24
+ REDIS_MIN_IDLE_CONNS=10
25
+ REDIS_POOL_SIZE=10
26
+ REDIS_POOL_TIMEOUT=30s
space/space/pkg/validation/custom_rules.go CHANGED
@@ -32,7 +32,7 @@ var inMemoryOptions = map[string][]string{
32
  "body_shape": {"Ideal", "Kurus", "Berisi", "Gemuk"},
33
  "skin_color": {"Putih", "Kuning Langsat", "Sawo Matang"},
34
  "hair_type": {"Lurus", "Bergelombang", "Keriting"},
35
- "frequently": {"Selalu", "Sering", "Kadang", "Jarang"},
36
  "quran_reading_ability": {"Lancar", "Menengah", "Perlu Bimbingan"},
37
  }
38
 
 
32
  "body_shape": {"Ideal", "Kurus", "Berisi", "Gemuk"},
33
  "skin_color": {"Putih", "Kuning Langsat", "Sawo Matang"},
34
  "hair_type": {"Lurus", "Bergelombang", "Keriting"},
35
+ "frequently": {"Selalu", "Sering", "Kadang", "Jarang", "Tidak Pernah"},
36
  "quran_reading_ability": {"Lancar", "Menengah", "Perlu Bimbingan"},
37
  }
38
 
space/space/space/services/cv_service.go CHANGED
@@ -3,7 +3,6 @@ package services
3
  import (
4
  "context"
5
  "errors"
6
- "fmt"
7
  "math"
8
  "strconv"
9
 
@@ -648,26 +647,25 @@ func calculateProgress(
648
  achievementCV int,
649
  ) *models.ProgressResponse {
650
 
651
- exists := func(data int) float64 {
 
652
  if data > 0 {
653
  return 100
654
  }
655
  return 0
656
  }
657
 
658
- fmt.Println("accountDetails", accountDetails.GetFilledFields())
659
- fmt.Println("accountDetailsTotal", accountDetails.TotalFields())
660
-
661
  accountDetailsPercentage := float64(len(accountDetails.GetFilledFields())) / float64(accountDetails.TotalFields()) * 100
662
  personalityAndPreferenceCVPercentage := float64(len(personalityAndPreferenceCV.GetFilledFields())) / float64(personalityAndPreferenceCV.TotalFields()) * 100
663
  physicalAndHealthCVPercentage := float64(len(physicalAndHealthCV.GetFilledFields())) / float64(physicalAndHealthCV.TotalFields()) * 100
664
  worshipAndReligiousUnderstandingCVPercentage := float64(len(worshipAndReligiousUnderstandingCV.GetFilledFields())) / float64(worshipAndReligiousUnderstandingCV.TotalFields()) * 100
665
- edicationCVPercentage := exists(educationCV)
666
- familyMemberCVPercentage := exists(familyMemberCV)
667
- jobCVPercentage := exists(jobCV)
668
- achievementCVPercentage := exists(achievementCV)
669
 
670
- overallProgress := (accountDetailsPercentage + personalityAndPreferenceCVPercentage + physicalAndHealthCVPercentage + worshipAndReligiousUnderstandingCVPercentage + edicationCVPercentage + familyMemberCVPercentage + jobCVPercentage + achievementCVPercentage) / 8
 
 
 
 
 
671
 
672
  return &models.ProgressResponse{
673
  AccountDetailsProgress: math.Round(accountDetailsPercentage*100) / 100,
@@ -675,9 +673,9 @@ func calculateProgress(
675
  FamilyMemberCVProgress: math.Round(familyMemberCVPercentage*100) / 100,
676
  PhysicalAndHealthCVProgress: math.Round(physicalAndHealthCVPercentage*100) / 100,
677
  WorshipAndReligiousUnderstandingCVProgress: math.Round(worshipAndReligiousUnderstandingCVPercentage*100) / 100,
678
- EducationCVProgress: math.Round(edicationCVPercentage*100) / 100,
679
- JobCVProgress: jobCVPercentage,
680
- AchievementCVProgress: achievementCVPercentage,
681
  TotalProgress: math.Round(overallProgress*100) / 100,
682
  }
683
  }
 
3
  import (
4
  "context"
5
  "errors"
 
6
  "math"
7
  "strconv"
8
 
 
647
  achievementCV int,
648
  ) *models.ProgressResponse {
649
 
650
+ // fullIfPresent returns 100 if the data is greater than 0, otherwise returns 0
651
+ fullIfPresent := func(data int) float64 {
652
  if data > 0 {
653
  return 100
654
  }
655
  return 0
656
  }
657
 
 
 
 
658
  accountDetailsPercentage := float64(len(accountDetails.GetFilledFields())) / float64(accountDetails.TotalFields()) * 100
659
  personalityAndPreferenceCVPercentage := float64(len(personalityAndPreferenceCV.GetFilledFields())) / float64(personalityAndPreferenceCV.TotalFields()) * 100
660
  physicalAndHealthCVPercentage := float64(len(physicalAndHealthCV.GetFilledFields())) / float64(physicalAndHealthCV.TotalFields()) * 100
661
  worshipAndReligiousUnderstandingCVPercentage := float64(len(worshipAndReligiousUnderstandingCV.GetFilledFields())) / float64(worshipAndReligiousUnderstandingCV.TotalFields()) * 100
 
 
 
 
662
 
663
+ educationCVPercentage := fullIfPresent(educationCV)
664
+ familyMemberCVPercentage := fullIfPresent(familyMemberCV)
665
+ jobCVPercentage := fullIfPresent(jobCV)
666
+ achievementCVPercentage := fullIfPresent(achievementCV)
667
+
668
+ overallProgress := (accountDetailsPercentage + personalityAndPreferenceCVPercentage + physicalAndHealthCVPercentage + worshipAndReligiousUnderstandingCVPercentage + educationCVPercentage + familyMemberCVPercentage + jobCVPercentage + achievementCVPercentage) / 8
669
 
670
  return &models.ProgressResponse{
671
  AccountDetailsProgress: math.Round(accountDetailsPercentage*100) / 100,
 
673
  FamilyMemberCVProgress: math.Round(familyMemberCVPercentage*100) / 100,
674
  PhysicalAndHealthCVProgress: math.Round(physicalAndHealthCVPercentage*100) / 100,
675
  WorshipAndReligiousUnderstandingCVProgress: math.Round(worshipAndReligiousUnderstandingCVPercentage*100) / 100,
676
+ EducationCVProgress: math.Round(educationCVPercentage*100) / 100,
677
+ JobCVProgress: math.Round(jobCVPercentage*100) / 100,
678
+ AchievementCVProgress: math.Round(achievementCVPercentage*100) / 100,
679
  TotalProgress: math.Round(overallProgress*100) / 100,
680
  }
681
  }
space/space/space/space/controller/cv/cv_controller.go CHANGED
@@ -1,13 +1,14 @@
1
  package cv_controller
2
 
3
  import (
 
 
 
4
  "api.qobiltu.id/middleware"
5
  "api.qobiltu.id/models"
6
  "api.qobiltu.id/response"
7
  "api.qobiltu.id/services"
8
  "github.com/gin-gonic/gin"
9
- "net/http"
10
- "strconv"
11
  )
12
 
13
  type CVController interface {
@@ -48,6 +49,7 @@ type CVController interface {
48
  DeleteAchievement(ctx *gin.Context)
49
 
50
  UploadProfileImage(ctx *gin.Context)
 
51
  }
52
 
53
  type cvController struct {
@@ -573,3 +575,16 @@ func (c *cvController) UploadProfileImage(ctx *gin.Context) {
573
 
574
  response.HandleSuccess(ctx, http.StatusOK, "Profile image uploaded successfully", res, nil)
575
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  package cv_controller
2
 
3
  import (
4
+ "net/http"
5
+ "strconv"
6
+
7
  "api.qobiltu.id/middleware"
8
  "api.qobiltu.id/models"
9
  "api.qobiltu.id/response"
10
  "api.qobiltu.id/services"
11
  "github.com/gin-gonic/gin"
 
 
12
  )
13
 
14
  type CVController interface {
 
49
  DeleteAchievement(ctx *gin.Context)
50
 
51
  UploadProfileImage(ctx *gin.Context)
52
+ GetProgress(ctx *gin.Context)
53
  }
54
 
55
  type cvController struct {
 
575
 
576
  response.HandleSuccess(ctx, http.StatusOK, "Profile image uploaded successfully", res, nil)
577
  }
578
+
579
+ func (c *cvController) GetProgress(ctx *gin.Context) {
580
+ accountData := middleware.GetAccountData(ctx)
581
+ accountID := int64(accountData.UserID)
582
+
583
+ res, err := c.cvService.GetProgress(ctx, accountID)
584
+ if err != nil {
585
+ response.HandleError(ctx, err)
586
+ return
587
+ }
588
+
589
+ response.HandleSuccess(ctx, http.StatusOK, "Progress retrieved successfully", res, nil)
590
+ }
space/space/space/space/models/field_counter.go ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package models
2
+
3
+ import (
4
+ "reflect"
5
+ "time"
6
+ )
7
+
8
+ // CounterGetter adalah interface yang menyediakan method untuk menghitung dan mendapatkan field
9
+ type CounterGetter interface {
10
+ TotalFields() int
11
+ GetFilledFields() []string
12
+ }
13
+
14
+ // FieldCounter menyediakan fungsionalitas generik penghitungan dan pengecekan field
15
+ // untuk diembed ke dalam struct lain
16
+ type FieldCounter struct{}
17
+
18
+ // shouldSkipField menentukan apakah field harus dilewati dalam penghitungan
19
+ func shouldSkipField(field reflect.StructField) bool {
20
+ // Skip field dengan nama FieldCounter (embedded type)
21
+ if field.Name == "FieldCounter" {
22
+ return true
23
+ }
24
+
25
+ // Skip field yang berasal dari GORM atau standard fields
26
+ // seperti ID, timestamps, dan foreign keys
27
+ if field.Name == "ID" || field.Name == "CreatedAt" || field.Name == "UpdatedAt" || field.Name == "DeletedAt" ||
28
+ field.Name == "AccountID" || field.Name == "Account" {
29
+ return true
30
+ }
31
+
32
+ // Periksa tag gorm untuk auto fields
33
+ gormTag := field.Tag.Get("gorm")
34
+ if len(gormTag) > 0 {
35
+ // Skip kolom yang auto-increment atau auto-timestamp
36
+ if gormTag == "primaryKey" || gormTag == "autoIncrement" ||
37
+ gormTag == "autoCreateTime" || gormTag == "autoUpdateTime" {
38
+ return true
39
+ }
40
+ }
41
+
42
+ return false
43
+ }
44
+
45
+ // TotalFields menghitung jumlah total field dalam struct
46
+ // dengan mengecualikan field internal, metadata, dan embedded FieldCounter
47
+ func (fc FieldCounter) TotalFields(s any) int {
48
+ t := reflect.TypeOf(s)
49
+ count := 0
50
+
51
+ // Jika s adalah pointer, dapatkan elemen yang ditunjuknya
52
+ if t.Kind() == reflect.Ptr {
53
+ t = t.Elem()
54
+ }
55
+
56
+ // Pastikan kita berurusan dengan struct
57
+ if t.Kind() != reflect.Struct {
58
+ return 0
59
+ }
60
+
61
+ // Hitung field yang sesuai dengan kriteria
62
+ for i := 0; i < t.NumField(); i++ {
63
+ field := t.Field(i)
64
+
65
+ // Skip field yang memenuhi kriteria yang ditentukan
66
+ if shouldSkipField(field) {
67
+ continue
68
+ }
69
+
70
+ count++
71
+ }
72
+
73
+ return count
74
+ }
75
+
76
+ // GetFilledFields mengembalikan slice berisi nama field yang telah diisi dengan nilai
77
+ // (tidak nil, string tidak kosong, int/float tidak 0, dll)
78
+ func (fc FieldCounter) GetFilledFields(s any) []string {
79
+ var filledFields []string
80
+ v := reflect.ValueOf(s)
81
+
82
+ // Jika s adalah pointer, dapatkan elemen yang ditunjuknya
83
+ if v.Kind() == reflect.Ptr {
84
+ if v.IsNil() {
85
+ return filledFields
86
+ }
87
+ v = v.Elem()
88
+ }
89
+
90
+ // Pastikan kita berurusan dengan struct
91
+ if v.Kind() != reflect.Struct {
92
+ return filledFields
93
+ }
94
+
95
+ t := v.Type()
96
+
97
+ // Loop melalui semua field
98
+ for i := 0; i < v.NumField(); i++ {
99
+ field := v.Field(i)
100
+ fieldType := t.Field(i)
101
+ fieldName := fieldType.Name
102
+
103
+ // Skip field yang memenuhi kriteria yang ditentukan
104
+ if shouldSkipField(fieldType) {
105
+ continue
106
+ }
107
+
108
+ // Cek apakah field diisi berdasarkan tipenya
109
+ if isFieldFilled(field) {
110
+ filledFields = append(filledFields, fieldName)
111
+ }
112
+ }
113
+
114
+ return filledFields
115
+ }
116
+
117
+ // isFieldFilled memeriksa apakah field memiliki nilai non-zero
118
+ func isFieldFilled(field reflect.Value) bool {
119
+ // Jika field tidak dapat di-address atau diakses, anggap kosong
120
+ if !field.IsValid() || !field.CanInterface() {
121
+ return false
122
+ }
123
+
124
+ // Handle untuk pointer
125
+ if field.Kind() == reflect.Ptr {
126
+ if field.IsNil() {
127
+ return false
128
+ }
129
+ // Untuk pointer, periksa nilai yang ditunjuk
130
+ return isFieldFilled(field.Elem())
131
+ }
132
+
133
+ // Cek berdasarkan tipe data
134
+ switch field.Kind() {
135
+ case reflect.String:
136
+ return field.String() != ""
137
+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
138
+ return field.Int() != 0
139
+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
140
+ return field.Uint() != 0
141
+ case reflect.Float32, reflect.Float64:
142
+ return field.Float() != 0
143
+ case reflect.Bool:
144
+ return field.Bool()
145
+ case reflect.Slice, reflect.Map, reflect.Array:
146
+ return !field.IsNil() && field.Len() > 0
147
+ case reflect.Struct:
148
+ // Perlakuan khusus untuk time.Time
149
+ if field.Type() == reflect.TypeOf(time.Time{}) {
150
+ // Anggap time.Time kosong jika zero value atau tahun sangat awal
151
+ timeVal := field.Interface().(time.Time)
152
+ return !timeVal.IsZero() && timeVal.Year() > 1
153
+ }
154
+
155
+ // Untuk struct lain, bandingkan dengan zero value
156
+ return !reflect.DeepEqual(field.Interface(), reflect.Zero(field.Type()).Interface())
157
+ case reflect.Interface:
158
+ return !field.IsNil()
159
+ default:
160
+ // Untuk tipe lain, gunakan perbandingan dengan zero value
161
+ return !field.IsZero()
162
+ }
163
+ }
space/space/space/space/models/request_model.go CHANGED
@@ -161,4 +161,16 @@ type (
161
  UploadProfileImageResponse struct {
162
  URL string `json:"url"`
163
  }
 
 
 
 
 
 
 
 
 
 
 
 
164
  )
 
161
  UploadProfileImageResponse struct {
162
  URL string `json:"url"`
163
  }
164
+
165
+ ProgressResponse struct {
166
+ AccountDetailsProgress float64 `json:"account_details_progress"`
167
+ PersonalityAndPreferenceCVProgress float64 `json:"personality_and_preference_cv_progress"`
168
+ FamilyMemberCVProgress float64 `json:"family_member_cv_progress"`
169
+ PhysicalAndHealthCVProgress float64 `json:"physical_and_health_cv_progress"`
170
+ WorshipAndReligiousUnderstandingCVProgress float64 `json:"worship_and_religious_understanding_cv_progress"`
171
+ EducationCVProgress float64 `json:"education_cv_progress"`
172
+ JobCVProgress float64 `json:"job_cv_progress"`
173
+ AchievementCVProgress float64 `json:"achievement_cv_progress"`
174
+ TotalProgress float64 `json:"total_progress"`
175
+ }
176
  )
space/space/space/space/router/cv_route.go CHANGED
@@ -41,5 +41,7 @@ func (s *Server) CVRoute() {
41
  routerGroup.GET("/achievements/:id", s.cvController.GetAchievement)
42
  routerGroup.PUT("/achievements/:id", s.cvController.UpdateAchievement)
43
  routerGroup.DELETE("/achievements/:id", s.cvController.DeleteAchievement)
 
 
44
  }
45
  }
 
41
  routerGroup.GET("/achievements/:id", s.cvController.GetAchievement)
42
  routerGroup.PUT("/achievements/:id", s.cvController.UpdateAchievement)
43
  routerGroup.DELETE("/achievements/:id", s.cvController.DeleteAchievement)
44
+
45
+ routerGroup.GET("/progress", s.cvController.GetProgress)
46
  }
47
  }
space/space/space/space/services/cv_service.go CHANGED
@@ -3,6 +3,8 @@ package services
3
  import (
4
  "context"
5
  "errors"
 
 
6
  "strconv"
7
 
8
  "api.qobiltu.id/models"
@@ -51,6 +53,7 @@ type CVService interface {
51
  DeleteAchievement(ctx context.Context, id int64) error
52
 
53
  UploadProfileImage(ctx context.Context, req *models.UploadProfileImageRequest) (*models.UploadProfileImageResponse, error)
 
54
  }
55
 
56
  type cvService struct {
@@ -566,3 +569,115 @@ func (s *cvService) UploadProfileImage(ctx context.Context, req *models.UploadPr
566
  URL: url,
567
  }, nil
568
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  import (
4
  "context"
5
  "errors"
6
+ "fmt"
7
+ "math"
8
  "strconv"
9
 
10
  "api.qobiltu.id/models"
 
53
  DeleteAchievement(ctx context.Context, id int64) error
54
 
55
  UploadProfileImage(ctx context.Context, req *models.UploadProfileImageRequest) (*models.UploadProfileImageResponse, error)
56
+ GetProgress(ctx context.Context, accountID int64) (*models.ProgressResponse, error)
57
  }
58
 
59
  type cvService struct {
 
569
  URL: url,
570
  }, nil
571
  }
572
+
573
+ func (s *cvService) GetProgress(ctx context.Context, accountID int64) (*models.ProgressResponse, error) {
574
+ accountDetails, err := s.cvRepository.GetAccountDetailsByAccountID(ctx, accountID)
575
+ if err != nil {
576
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
577
+ return nil, response.HandleGormError(err, "Internal Server Error")
578
+ }
579
+ accountDetails = &models.AccountDetails{}
580
+ }
581
+
582
+ personalityAndPreferenceCV, err := s.cvRepository.GetPersonalityAndPreferenceByAccountID(ctx, accountID)
583
+ if err != nil {
584
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
585
+ return nil, response.HandleGormError(err, "Internal Server Error")
586
+ }
587
+ personalityAndPreferenceCV = &models.PersonalityAndPreferenceCV{}
588
+ }
589
+
590
+ physicalAndHealthCV, err := s.cvRepository.GetPhysicalAndHealthByAccountID(ctx, accountID)
591
+ if err != nil {
592
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
593
+ return nil, response.HandleGormError(err, "Internal Server Error")
594
+ }
595
+ physicalAndHealthCV = &models.PhysicalAndHealthCV{}
596
+ }
597
+
598
+ worshipAndReligiousUnderstandingCV, err := s.cvRepository.GetWorshipAndReligiousUnderstandingByAccountID(ctx, accountID)
599
+ if err != nil {
600
+ if !errors.Is(err, gorm.ErrRecordNotFound) {
601
+ return nil, response.HandleGormError(err, "Internal Server Error")
602
+ }
603
+ worshipAndReligiousUnderstandingCV = &models.WorshipAndReligiousUnderstandingCV{}
604
+ }
605
+
606
+ educationCV, err := s.cvRepository.ListEducation(ctx, accountID)
607
+ if err != nil {
608
+ return nil, response.HandleGormError(err, "Internal Server Error")
609
+ }
610
+
611
+ familyMemberCV, err := s.cvRepository.ListFamilyMember(ctx, accountID)
612
+ if err != nil {
613
+ return nil, response.HandleGormError(err, "Internal Server Error")
614
+ }
615
+
616
+ jobCV, err := s.cvRepository.ListJob(ctx, accountID)
617
+ if err != nil {
618
+ return nil, response.HandleGormError(err, "Internal Server Error")
619
+ }
620
+
621
+ achievementCV, err := s.cvRepository.ListAchievement(ctx, accountID)
622
+ if err != nil {
623
+ return nil, response.HandleGormError(err, "Internal Server Error")
624
+ }
625
+
626
+ progressDetails := calculateProgress(
627
+ accountDetails,
628
+ personalityAndPreferenceCV,
629
+ physicalAndHealthCV,
630
+ worshipAndReligiousUnderstandingCV,
631
+ len(educationCV),
632
+ len(familyMemberCV),
633
+ len(jobCV),
634
+ len(achievementCV),
635
+ )
636
+
637
+ return progressDetails, nil
638
+ }
639
+
640
+ func calculateProgress(
641
+ accountDetails *models.AccountDetails,
642
+ personalityAndPreferenceCV *models.PersonalityAndPreferenceCV,
643
+ physicalAndHealthCV *models.PhysicalAndHealthCV,
644
+ worshipAndReligiousUnderstandingCV *models.WorshipAndReligiousUnderstandingCV,
645
+ educationCV int,
646
+ familyMemberCV int,
647
+ jobCV int,
648
+ achievementCV int,
649
+ ) *models.ProgressResponse {
650
+
651
+ exists := func(data int) float64 {
652
+ if data > 0 {
653
+ return 100
654
+ }
655
+ return 0
656
+ }
657
+
658
+ fmt.Println("accountDetails", accountDetails.GetFilledFields())
659
+ fmt.Println("accountDetailsTotal", accountDetails.TotalFields())
660
+
661
+ accountDetailsPercentage := float64(len(accountDetails.GetFilledFields())) / float64(accountDetails.TotalFields()) * 100
662
+ personalityAndPreferenceCVPercentage := float64(len(personalityAndPreferenceCV.GetFilledFields())) / float64(personalityAndPreferenceCV.TotalFields()) * 100
663
+ physicalAndHealthCVPercentage := float64(len(physicalAndHealthCV.GetFilledFields())) / float64(physicalAndHealthCV.TotalFields()) * 100
664
+ worshipAndReligiousUnderstandingCVPercentage := float64(len(worshipAndReligiousUnderstandingCV.GetFilledFields())) / float64(worshipAndReligiousUnderstandingCV.TotalFields()) * 100
665
+ edicationCVPercentage := exists(educationCV)
666
+ familyMemberCVPercentage := exists(familyMemberCV)
667
+ jobCVPercentage := exists(jobCV)
668
+ achievementCVPercentage := exists(achievementCV)
669
+
670
+ overallProgress := (accountDetailsPercentage + personalityAndPreferenceCVPercentage + physicalAndHealthCVPercentage + worshipAndReligiousUnderstandingCVPercentage + edicationCVPercentage + familyMemberCVPercentage + jobCVPercentage + achievementCVPercentage) / 8
671
+
672
+ return &models.ProgressResponse{
673
+ AccountDetailsProgress: math.Round(accountDetailsPercentage*100) / 100,
674
+ PersonalityAndPreferenceCVProgress: math.Round(personalityAndPreferenceCVPercentage*100) / 100,
675
+ FamilyMemberCVProgress: math.Round(familyMemberCVPercentage*100) / 100,
676
+ PhysicalAndHealthCVProgress: math.Round(physicalAndHealthCVPercentage*100) / 100,
677
+ WorshipAndReligiousUnderstandingCVProgress: math.Round(worshipAndReligiousUnderstandingCVPercentage*100) / 100,
678
+ EducationCVProgress: math.Round(edicationCVPercentage*100) / 100,
679
+ JobCVProgress: jobCVPercentage,
680
+ AchievementCVProgress: achievementCVPercentage,
681
+ TotalProgress: math.Round(overallProgress*100) / 100,
682
+ }
683
+ }
space/space/space/space/space/models/database_orm_model.go CHANGED
@@ -34,6 +34,7 @@ type AccountDetails struct {
34
  PhoneNumber *string `gorm:"column:phone_number" json:"phone_number"`
35
  CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
36
  UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
 
37
  }
38
 
39
  type EmailVerification struct {
@@ -154,15 +155,16 @@ type Question struct {
154
  CorrectAnswer uint `json:"-"`
155
  }
156
  type Quiz struct {
157
- ID uint `gorm:"primaryKey" json:"id"`
158
- AcademyID uint `json:"academy_id"`
159
- Slug string `json:"slug" gorm:"uniqueIndex" `
160
- Title string `json:"title"`
161
- Description string `json:"description"`
162
- AttemptLimit int `json:"attempt_limit"`
163
- TimeLimit int `json:"time_limit"`
164
- MinScore int `json:"min_score"`
165
- CreatedAt time.Time `json:"created_at"`
 
166
  }
167
 
168
  type QuizAttempt struct {
@@ -209,6 +211,7 @@ type (
209
  MonthlyExpenses *string `gorm:"column:monthly_expenses" json:"monthly_expenses"` // pengeluaran per bulan
210
  CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
211
  UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
 
212
  }
213
 
214
  FamilyMemberCV struct {
@@ -223,6 +226,7 @@ type (
223
  Age *int `gorm:"column:age" json:"age"` // Usia
224
  CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
225
  UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
 
226
  }
227
 
228
  PhysicalAndHealthCV struct {
@@ -239,6 +243,7 @@ type (
239
  PhysicalTraits *string `gorm:"column:physical_traits" json:"physical_traits"` // Ciri khas fisik
240
  CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` // Waktu data dibuat
241
  UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` // Waktu data terakhir diperbarui
 
242
  }
243
 
244
  WorshipAndReligiousUnderstandingCV struct {
@@ -262,6 +267,7 @@ type (
262
  FollowedUstadz *string `gorm:"column:followed_ustadz" json:"followed_ustadz"` // ustadz_yang_diikuti
263
  CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
264
  UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
 
265
  }
266
 
267
  EducationCV struct {
@@ -300,6 +306,38 @@ type (
300
  }
301
  )
302
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  // Gorm table name settings
304
  func (Account) TableName() string { return "account" }
305
  func (AccountDetails) TableName() string { return "account_details" }
 
34
  PhoneNumber *string `gorm:"column:phone_number" json:"phone_number"`
35
  CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
36
  UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
37
+ FieldCounter
38
  }
39
 
40
  type EmailVerification struct {
 
155
  CorrectAnswer uint `json:"-"`
156
  }
157
  type Quiz struct {
158
+ ID uint `gorm:"primaryKey" json:"id"`
159
+ AcademyID uint `json:"academy_id"`
160
+ Slug string `json:"slug" gorm:"uniqueIndex" `
161
+ Title string `json:"title"`
162
+ Description string `json:"description"`
163
+ TotalQuestions string `json:"total_questions"`
164
+ AttemptLimit int `json:"attempt_limit"`
165
+ TimeLimit int `json:"time_limit"`
166
+ MinScore int `json:"min_score"`
167
+ CreatedAt time.Time `json:"created_at"`
168
  }
169
 
170
  type QuizAttempt struct {
 
211
  MonthlyExpenses *string `gorm:"column:monthly_expenses" json:"monthly_expenses"` // pengeluaran per bulan
212
  CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
213
  UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
214
+ FieldCounter
215
  }
216
 
217
  FamilyMemberCV struct {
 
226
  Age *int `gorm:"column:age" json:"age"` // Usia
227
  CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
228
  UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
229
+ FieldCounter
230
  }
231
 
232
  PhysicalAndHealthCV struct {
 
243
  PhysicalTraits *string `gorm:"column:physical_traits" json:"physical_traits"` // Ciri khas fisik
244
  CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` // Waktu data dibuat
245
  UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` // Waktu data terakhir diperbarui
246
+ FieldCounter
247
  }
248
 
249
  WorshipAndReligiousUnderstandingCV struct {
 
267
  FollowedUstadz *string `gorm:"column:followed_ustadz" json:"followed_ustadz"` // ustadz_yang_diikuti
268
  CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
269
  UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
270
+ FieldCounter
271
  }
272
 
273
  EducationCV struct {
 
306
  }
307
  )
308
 
309
+ func (a AccountDetails) TotalFields() int {
310
+ return a.FieldCounter.TotalFields(a)
311
+ }
312
+
313
+ func (a AccountDetails) GetFilledFields() []string {
314
+ return a.FieldCounter.GetFilledFields(a)
315
+ }
316
+
317
+ func (p PersonalityAndPreferenceCV) TotalFields() int {
318
+ return p.FieldCounter.TotalFields(p)
319
+ }
320
+
321
+ func (p PersonalityAndPreferenceCV) GetFilledFields() []string {
322
+ return p.FieldCounter.GetFilledFields(p)
323
+ }
324
+
325
+ func (p PhysicalAndHealthCV) TotalFields() int {
326
+ return p.FieldCounter.TotalFields(p)
327
+ }
328
+
329
+ func (p PhysicalAndHealthCV) GetFilledFields() []string {
330
+ return p.FieldCounter.GetFilledFields(p)
331
+ }
332
+
333
+ func (w WorshipAndReligiousUnderstandingCV) TotalFields() int {
334
+ return w.FieldCounter.TotalFields(w)
335
+ }
336
+
337
+ func (w WorshipAndReligiousUnderstandingCV) GetFilledFields() []string {
338
+ return w.FieldCounter.GetFilledFields(w)
339
+ }
340
+
341
  // Gorm table name settings
342
  func (Account) TableName() string { return "account" }
343
  func (AccountDetails) TableName() string { return "account_details" }
space/space/space/space/space/repositories/quiz_repository.go CHANGED
@@ -49,9 +49,12 @@ func GetUserLastAttempt(userId uint, quizId uint) Repository[models.QuizAttempt,
49
  return *repo
50
  }
51
 
52
- func GetAttemptById(attemptId uint) Repository[models.QuizAttempt, models.QuizAttempt] {
53
  repo := Construct[models.QuizAttempt, models.QuizAttempt](
54
- models.QuizAttempt{ID: attemptId},
 
 
 
55
  )
56
  repo.Transactions(
57
  WhereGivenConstructor[models.QuizAttempt, models.QuizAttempt],
 
49
  return *repo
50
  }
51
 
52
+ func GetAttemptByIdandUserId(attemptId uint, userId uint) Repository[models.QuizAttempt, models.QuizAttempt] {
53
  repo := Construct[models.QuizAttempt, models.QuizAttempt](
54
+ models.QuizAttempt{
55
+ ID: attemptId,
56
+ AccountID: userId,
57
+ },
58
  )
59
  repo.Transactions(
60
  WhereGivenConstructor[models.QuizAttempt, models.QuizAttempt],
space/space/space/space/space/services/academy_quiz_service.go CHANGED
@@ -131,7 +131,7 @@ func (s *AttemptQuizService) Create(userID uint) {
131
  }
132
 
133
  func (s *SubmitQuizService) Create(userID uint) {
134
- quizAttemptRepo := repositories.GetAttemptById(s.Constructor.ID)
135
  if quizAttemptRepo.NoRecord {
136
  s.Exception.DataNotFound = true
137
  s.Exception.Message = "There is no quiz attempt with given user!"
 
131
  }
132
 
133
  func (s *SubmitQuizService) Create(userID uint) {
134
+ quizAttemptRepo := repositories.GetAttemptByIdandUserId(s.Constructor.ID, userID)
135
  if quizAttemptRepo.NoRecord {
136
  s.Exception.DataNotFound = true
137
  s.Exception.Message = "There is no quiz attempt with given user!"
space/space/space/space/space/space/pkg/validation/custom_rules.go CHANGED
@@ -1,6 +1,7 @@
1
  package validation
2
 
3
  import (
 
4
  "strings"
5
  "sync"
6
 
@@ -150,6 +151,11 @@ func (v *Validator) RegisterAllCustomRules(validate *v10.Validate) error {
150
  return err
151
  }
152
 
 
 
 
 
 
153
  return nil
154
  }
155
 
@@ -160,3 +166,39 @@ func (v *Validator) PasswordRule(fl v10.FieldLevel) bool {
160
  strings.ContainsAny(password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") &&
161
  strings.ContainsAny(password, "0123456789")
162
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  package validation
2
 
3
  import (
4
+ "regexp"
5
  "strings"
6
  "sync"
7
 
 
151
  return err
152
  }
153
 
154
+ err = validate.RegisterValidation("phone_number", v.PhoneNumberRule)
155
+ if err != nil {
156
+ return err
157
+ }
158
+
159
  return nil
160
  }
161
 
 
166
  strings.ContainsAny(password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") &&
167
  strings.ContainsAny(password, "0123456789")
168
  }
169
+
170
+ func (v *Validator) PhoneNumberRule(fl v10.FieldLevel) bool {
171
+ phone := SanitizePhoneNumber(fl.Field().String())
172
+ return strings.HasPrefix(phone, "+62")
173
+ }
174
+
175
+ func SanitizePhoneNumber(input string) string {
176
+ // Hilangkan semua spasi dan strip
177
+ input = strings.ReplaceAll(input, " ", "")
178
+ input = strings.ReplaceAll(input, "-", "")
179
+ input = strings.ReplaceAll(input, "(", "")
180
+ input = strings.ReplaceAll(input, ")", "")
181
+
182
+ // Hilangkan semua karakter non-digit kecuali +
183
+ re := regexp.MustCompile(`[^0-9\+]`)
184
+ input = re.ReplaceAllString(input, "")
185
+
186
+ // Handle nomor diawali 0 (contoh: 0812...) menjadi +62812...
187
+ if strings.HasPrefix(input, "0") {
188
+ input = "+62" + input[1:]
189
+ }
190
+
191
+ // Handle jika diawali dengan 62 tanpa + (contoh: 62812...)
192
+ if strings.HasPrefix(input, "62") && !strings.HasPrefix(input, "+62") {
193
+ input = "+" + input
194
+ }
195
+
196
+ // Handle jika tidak ada awalan +62 sama sekali (contoh: 8123456789)
197
+ if !strings.HasPrefix(input, "+62") {
198
+ if strings.HasPrefix(input, "8") {
199
+ input = "+62" + input
200
+ }
201
+ }
202
+
203
+ return input
204
+ }
space/space/space/space/space/space/pkg/validation/validation.go CHANGED
@@ -79,6 +79,7 @@ func setupValidations(validate *v10.Validate) error {
79
  if err := rules.RegisterAllCustomRules(validate); err != nil {
80
  return err
81
  }
 
82
  return nil
83
  }
84
 
 
79
  if err := rules.RegisterAllCustomRules(validate); err != nil {
80
  return err
81
  }
82
+
83
  return nil
84
  }
85
 
space/space/space/space/space/space/space/Makefile CHANGED
@@ -1,7 +1,15 @@
1
  up-dev:
2
  docker compose -f docker-compose.dev.yml up -d
3
 
 
 
 
 
 
 
 
 
4
  run-dev:
5
  go run main.go
6
 
7
- .PHONY : up-dev run-dev
 
1
  up-dev:
2
  docker compose -f docker-compose.dev.yml up -d
3
 
4
+ down-dev:
5
+ docker compose -f docker-compose.dev.yml down
6
+
7
+ up:
8
+ docker compose up -d --build
9
+ down:
10
+ docker compose down
11
+
12
  run-dev:
13
  go run main.go
14
 
15
+ .PHONY : up-dev run-dev down-dev up down
space/space/space/space/space/space/space/config/config.go CHANGED
@@ -29,6 +29,8 @@ var REDIS_MIN_IDLE_CONNS int
29
  var REDIS_POOL_SIZE int
30
  var REDIS_POOL_TIMEOUT time.Duration
31
 
 
 
32
  func init() {
33
  godotenv.Load()
34
  ENV = os.Getenv("ENV")
@@ -49,6 +51,7 @@ func init() {
49
  REDIS_POOL_SIZE = getValue(os.Getenv("REDIS_POOL_SIZE"), 10, func(s string) (int, error) { return strconv.Atoi(s) })
50
  REDIS_MIN_IDLE_CONNS = getValue(os.Getenv("REDIS_MIN_IDLE_CONNS"), 10, func(s string) (int, error) { return strconv.Atoi(s) })
51
  REDIS_POOL_TIMEOUT = getValue(os.Getenv("REDIS_POOL_TIMEOUT"), time.Second*30, func(s string) (time.Duration, error) { return time.ParseDuration(s) })
 
52
  }
53
 
54
  func getValue[T any](value string, defaultValue T, convert func(string) (T, error)) T {
 
29
  var REDIS_POOL_SIZE int
30
  var REDIS_POOL_TIMEOUT time.Duration
31
 
32
+ var APP_URL string
33
+
34
  func init() {
35
  godotenv.Load()
36
  ENV = os.Getenv("ENV")
 
51
  REDIS_POOL_SIZE = getValue(os.Getenv("REDIS_POOL_SIZE"), 10, func(s string) (int, error) { return strconv.Atoi(s) })
52
  REDIS_MIN_IDLE_CONNS = getValue(os.Getenv("REDIS_MIN_IDLE_CONNS"), 10, func(s string) (int, error) { return strconv.Atoi(s) })
53
  REDIS_POOL_TIMEOUT = getValue(os.Getenv("REDIS_POOL_TIMEOUT"), time.Second*30, func(s string) (time.Duration, error) { return time.ParseDuration(s) })
54
+ APP_URL = getValue(os.Getenv("APP_URL"), "http://localhost:3000", func(s string) (string, error) { return s, nil })
55
  }
56
 
57
  func getValue[T any](value string, defaultValue T, convert func(string) (T, error)) T {
space/space/space/space/space/space/space/controller/cv/cv_controller.go CHANGED
@@ -46,6 +46,8 @@ type CVController interface {
46
  ListAchievement(ctx *gin.Context)
47
  GetAchievement(ctx *gin.Context)
48
  DeleteAchievement(ctx *gin.Context)
 
 
49
  }
50
 
51
  type cvController struct {
@@ -553,3 +555,21 @@ func (c *cvController) DeleteAchievement(ctx *gin.Context) {
553
 
554
  response.HandleSuccess(ctx, http.StatusOK, "Achievement deleted successfully", nil, nil)
555
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  ListAchievement(ctx *gin.Context)
47
  GetAchievement(ctx *gin.Context)
48
  DeleteAchievement(ctx *gin.Context)
49
+
50
+ UploadProfileImage(ctx *gin.Context)
51
  }
52
 
53
  type cvController struct {
 
555
 
556
  response.HandleSuccess(ctx, http.StatusOK, "Achievement deleted successfully", nil, nil)
557
  }
558
+
559
+ func (c *cvController) UploadProfileImage(ctx *gin.Context) {
560
+ accountData := middleware.GetAccountData(ctx)
561
+
562
+ avatarFile, _ := ctx.FormFile("avatar")
563
+ req := models.UploadProfileImageRequest{
564
+ AccountID: int64(accountData.UserID),
565
+ File: avatarFile,
566
+ }
567
+
568
+ res, err := c.cvService.UploadProfileImage(ctx, &req)
569
+ if err != nil {
570
+ response.HandleError(ctx, err)
571
+ return
572
+ }
573
+
574
+ response.HandleSuccess(ctx, http.StatusOK, "Profile image uploaded successfully", res, nil)
575
+ }
space/space/space/space/space/space/space/docker-compose.yml CHANGED
@@ -6,14 +6,15 @@ services:
6
  build: .
7
  depends_on:
8
  - db
 
9
  env_file: .env
10
  ports:
11
  - "8080:8080"
 
 
 
12
  networks:
13
  - qobiltu-network
14
- # volumes:
15
- # - ./logs:/app/logs
16
- # - /home/qobiltu/api-qobiltu:/app
17
  restart: unless-stopped
18
 
19
  db:
@@ -45,10 +46,6 @@ services:
45
  - qobiltu-network
46
  restart: always
47
 
48
- volumes:
49
- db-data:
50
- redis-data:
51
-
52
  networks:
53
  qobiltu-network:
54
  driver: bridge
 
6
  build: .
7
  depends_on:
8
  - db
9
+ - redis
10
  env_file: .env
11
  ports:
12
  - "8080:8080"
13
+ volumes:
14
+ - ./logs:/app/logs
15
+ - ./uploads:/app/uploads
16
  networks:
17
  - qobiltu-network
 
 
 
18
  restart: unless-stopped
19
 
20
  db:
 
46
  - qobiltu-network
47
  restart: always
48
 
 
 
 
 
49
  networks:
50
  qobiltu-network:
51
  driver: bridge
space/space/space/space/space/space/space/go.mod CHANGED
@@ -8,6 +8,7 @@ require (
8
  github.com/go-playground/universal-translator v0.18.1
9
  github.com/go-playground/validator/v10 v10.25.0
10
  github.com/golang-jwt/jwt/v5 v5.2.1
 
11
  github.com/gosimple/slug v1.15.0
12
  github.com/hibiken/asynq v0.25.1
13
  github.com/joho/godotenv v1.5.1
@@ -37,7 +38,6 @@ require (
37
  github.com/go-logr/stdr v1.2.2 // indirect
38
  github.com/goccy/go-json v0.10.5 // indirect
39
  github.com/google/s2a-go v0.1.9 // indirect
40
- github.com/google/uuid v1.6.0 // indirect
41
  github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
42
  github.com/googleapis/gax-go/v2 v2.14.1 // indirect
43
  github.com/gosimple/unidecode v1.0.1 // indirect
 
8
  github.com/go-playground/universal-translator v0.18.1
9
  github.com/go-playground/validator/v10 v10.25.0
10
  github.com/golang-jwt/jwt/v5 v5.2.1
11
+ github.com/google/uuid v1.6.0
12
  github.com/gosimple/slug v1.15.0
13
  github.com/hibiken/asynq v0.25.1
14
  github.com/joho/godotenv v1.5.1
 
38
  github.com/go-logr/stdr v1.2.2 // indirect
39
  github.com/goccy/go-json v0.10.5 // indirect
40
  github.com/google/s2a-go v0.1.9 // indirect
 
41
  github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect
42
  github.com/googleapis/gax-go/v2 v2.14.1 // indirect
43
  github.com/gosimple/unidecode v1.0.1 // indirect
space/space/space/space/space/space/space/main.go CHANGED
@@ -2,15 +2,16 @@ package main
2
 
3
  import (
4
  "api.qobiltu.id/config"
5
- cv_controller "api.qobiltu.id/controller/cv"
6
  "api.qobiltu.id/controller/health_check"
7
  "api.qobiltu.id/mail"
 
 
 
8
  "api.qobiltu.id/repositories"
9
  "api.qobiltu.id/router"
10
  "api.qobiltu.id/services"
11
  "api.qobiltu.id/utils"
12
- "api.qobiltu.id/validation"
13
- "api.qobiltu.id/worker"
14
  "github.com/hibiken/asynq"
15
  "log/slog"
16
  "net"
@@ -23,6 +24,9 @@ func main() {
23
  err := validation.New(validation.LocaleID)
24
  utils.FatalIfErr("failed to setup validator", err)
25
 
 
 
 
26
  // setup email sender
27
  emailConfig := mail.Config{
28
  Host: config.SMTP_HOST,
@@ -53,7 +57,7 @@ func main() {
53
  healthCheckController := health_check_controller.NewHealthCheckController(healthCheckService)
54
 
55
  cvRepository := repositories.NewCVRepository(config.DB)
56
- cvService := services.NewCVService(cvRepository)
57
  cvController := cv_controller.NewCVController(cvService)
58
 
59
  // start task processor
 
2
 
3
  import (
4
  "api.qobiltu.id/config"
5
+ "api.qobiltu.id/controller/cv"
6
  "api.qobiltu.id/controller/health_check"
7
  "api.qobiltu.id/mail"
8
+ "api.qobiltu.id/pkg/storage"
9
+ "api.qobiltu.id/pkg/validation"
10
+ "api.qobiltu.id/pkg/worker"
11
  "api.qobiltu.id/repositories"
12
  "api.qobiltu.id/router"
13
  "api.qobiltu.id/services"
14
  "api.qobiltu.id/utils"
 
 
15
  "github.com/hibiken/asynq"
16
  "log/slog"
17
  "net"
 
24
  err := validation.New(validation.LocaleID)
25
  utils.FatalIfErr("failed to setup validator", err)
26
 
27
+ // setup storage
28
+ localStorage := storage.NewLocalStorage("uploads", config.APP_URL+"/storage/")
29
+
30
  // setup email sender
31
  emailConfig := mail.Config{
32
  Host: config.SMTP_HOST,
 
57
  healthCheckController := health_check_controller.NewHealthCheckController(healthCheckService)
58
 
59
  cvRepository := repositories.NewCVRepository(config.DB)
60
+ cvService := services.NewCVService(cvRepository, localStorage)
61
  cvController := cv_controller.NewCVController(cvService)
62
 
63
  // start task processor
space/space/space/space/space/space/space/models/exception_model.go CHANGED
@@ -1,6 +1,8 @@
1
  package models
2
 
3
- import "api.qobiltu.id/validation"
 
 
4
 
5
  type Exception struct {
6
  Unauthorized bool `json:"unauthorized,omitempty"`
 
1
  package models
2
 
3
+ import (
4
+ "api.qobiltu.id/pkg/validation"
5
+ )
6
 
7
  type Exception struct {
8
  Unauthorized bool `json:"unauthorized,omitempty"`
space/space/space/space/space/space/space/models/request_model.go CHANGED
@@ -1,8 +1,10 @@
1
  package models
2
 
3
  import (
4
- "github.com/lib/pq"
5
  "time"
 
 
6
  )
7
 
8
  type LoginRequest struct {
@@ -106,6 +108,7 @@ type (
106
  MaritalStatus *string `json:"marital_status" validate:"marital_status"`
107
  LastEducation *string `json:"last_education" validate:"last_education"`
108
  LastJob *string `json:"last_job"`
 
109
  }
110
 
111
  WorshipAndReligiousUnderstandingRequest struct {
@@ -149,4 +152,13 @@ type (
149
  AccountID int64 `json:"account_id"`
150
  AchievementOrAward *string `json:"achievement_or_award"` // prestasi atau penghargaan
151
  }
 
 
 
 
 
 
 
 
 
152
  )
 
1
  package models
2
 
3
  import (
4
+ "mime/multipart"
5
  "time"
6
+
7
+ "github.com/lib/pq"
8
  )
9
 
10
  type LoginRequest struct {
 
108
  MaritalStatus *string `json:"marital_status" validate:"marital_status"`
109
  LastEducation *string `json:"last_education" validate:"last_education"`
110
  LastJob *string `json:"last_job"`
111
+ PhoneNumber *string `json:"phone_number" validate:"phone_number"`
112
  }
113
 
114
  WorshipAndReligiousUnderstandingRequest struct {
 
152
  AccountID int64 `json:"account_id"`
153
  AchievementOrAward *string `json:"achievement_or_award"` // prestasi atau penghargaan
154
  }
155
+
156
+ UploadProfileImageRequest struct {
157
+ AccountID int64
158
+ File *multipart.FileHeader
159
+ }
160
+
161
+ UploadProfileImageResponse struct {
162
+ URL string `json:"url"`
163
+ }
164
  )
space/space/space/space/space/space/space/pkg/storage/local.go ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package storage
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "io"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ )
11
+
12
+ type LocalStorage struct {
13
+ *BaseStorage
14
+ basePath string
15
+ baseURL string
16
+ }
17
+
18
+ func NewLocalStorage(basePath, baseURL string) *LocalStorage {
19
+ return &LocalStorage{
20
+ BaseStorage: NewBaseStorage(),
21
+ basePath: basePath,
22
+ baseURL: baseURL,
23
+ }
24
+ }
25
+
26
+ func (s *LocalStorage) Upload(ctx context.Context, file io.ReadSeeker, path string) error {
27
+ fullPath := filepath.Join(s.basePath, path)
28
+
29
+ dir := filepath.Dir(fullPath)
30
+ if err := os.MkdirAll(dir, 0755); err != nil {
31
+ return err
32
+ }
33
+
34
+ dst, err := os.Create(fullPath)
35
+ if err != nil {
36
+ return err
37
+ }
38
+ defer dst.Close()
39
+
40
+ if _, err := io.Copy(dst, file); err != nil {
41
+ return err
42
+ }
43
+ return nil
44
+ }
45
+
46
+ func (s *LocalStorage) GetURL(path string) string {
47
+ return filepath.Join(s.baseURL, path)
48
+ }
49
+
50
+ func (s *LocalStorage) Delete(ctx context.Context, path string) error {
51
+ // Contoh input: "http://localhost:8080/storage/users/5/profile/filename.png"
52
+
53
+ // Ambil bagian setelah "/storage/"
54
+ const prefix = "/storage/"
55
+ idx := strings.Index(path, prefix)
56
+ if idx == -1 {
57
+ return os.ErrNotExist
58
+ }
59
+
60
+ relativePath := path[idx+len(prefix):]
61
+
62
+ // Gabungkan dengan basePath
63
+ fullPath := filepath.Join(s.basePath, relativePath)
64
+ fmt.Println(fullPath)
65
+
66
+ return os.Remove(fullPath)
67
+ }
space/space/space/space/space/space/space/pkg/storage/storage.go ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package storage
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "github.com/google/uuid"
7
+ "io"
8
+ "path/filepath"
9
+ )
10
+
11
+ type FileType int
12
+
13
+ const (
14
+ ProfileImage FileType = iota
15
+ )
16
+
17
+ var (
18
+ ErrInvalidFileType = errors.New("invalid file type")
19
+ ErrFileTooLarge = errors.New("file too large")
20
+ ErrNoFileUploaded = errors.New("no file uploaded")
21
+ )
22
+
23
+ type Storage interface {
24
+ Upload(ctx context.Context, file io.ReadSeeker, path string) error
25
+ GetURL(path string) string
26
+ Delete(ctx context.Context, path string) error
27
+
28
+ ValidateExtension(fileType FileType, filename string) bool
29
+ GenerateFilename(originalName string) string
30
+ GetPath(fileType FileType, identifier string, filename string) string
31
+ }
32
+
33
+ var (
34
+ _ Storage = (*LocalStorage)(nil)
35
+ )
36
+
37
+ type BaseStorage struct {
38
+ allowedExtensions map[FileType][]string
39
+ }
40
+
41
+ func NewBaseStorage() *BaseStorage {
42
+ return &BaseStorage{
43
+ allowedExtensions: map[FileType][]string{
44
+ ProfileImage: {".jpg", ".jpeg", ".png", ".webp"},
45
+ },
46
+ }
47
+ }
48
+
49
+ func (bs *BaseStorage) ValidateExtension(fileType FileType, filename string) bool {
50
+ ext := filepath.Ext(filename)
51
+ for _, allowed := range bs.allowedExtensions[fileType] {
52
+ if ext == allowed {
53
+ return true
54
+ }
55
+ }
56
+ return false
57
+ }
58
+
59
+ func (bs *BaseStorage) GenerateFilename(originalName string) string {
60
+ ext := filepath.Ext(originalName)
61
+ return uuid.New().String() + ext
62
+ }
63
+
64
+ func (bs *BaseStorage) GetPath(fileType FileType, identifier string, filename string) string {
65
+ switch fileType {
66
+ case ProfileImage:
67
+ return filepath.Join("users", identifier, "profile", filename)
68
+ default:
69
+ return filepath.Join("misc", filename)
70
+ }
71
+ }
space/space/space/space/space/space/space/response/api_response_v2.go CHANGED
@@ -2,8 +2,8 @@ package response
2
 
3
  import (
4
  "api.qobiltu.id/models"
 
5
  "api.qobiltu.id/utils"
6
- "api.qobiltu.id/validation"
7
  "errors"
8
  "net/http"
9
 
 
2
 
3
  import (
4
  "api.qobiltu.id/models"
5
+ "api.qobiltu.id/pkg/validation"
6
  "api.qobiltu.id/utils"
 
7
  "errors"
8
  "net/http"
9
 
space/space/space/space/space/space/space/response/validation.go CHANGED
@@ -2,7 +2,7 @@ package response
2
 
3
  import (
4
  "api.qobiltu.id/models"
5
- "api.qobiltu.id/validation"
6
  )
7
 
8
  func HandleValidationError(validationErrors []validation.ErrorMessage) error {
 
2
 
3
  import (
4
  "api.qobiltu.id/models"
5
+ "api.qobiltu.id/pkg/validation"
6
  )
7
 
8
  func HandleValidationError(validationErrors []validation.ErrorMessage) error {
space/space/space/space/space/space/space/router/cv_route.go CHANGED
@@ -7,6 +7,7 @@ func (s *Server) CVRoute() {
7
  {
8
  routerGroup.POST("/account-details", s.cvController.SaveAccountDetails)
9
  routerGroup.GET("/account-details", s.cvController.GetAccountDetails)
 
10
 
11
  routerGroup.POST("/personality-and-preferences", s.cvController.SavePersonalityAndPreference)
12
  routerGroup.GET("/personality-and-preferences", s.cvController.GetPersonalityAndPreference)
 
7
  {
8
  routerGroup.POST("/account-details", s.cvController.SaveAccountDetails)
9
  routerGroup.GET("/account-details", s.cvController.GetAccountDetails)
10
+ routerGroup.POST("/account-details/avatar", s.cvController.UploadProfileImage)
11
 
12
  routerGroup.POST("/personality-and-preferences", s.cvController.SavePersonalityAndPreference)
13
  routerGroup.GET("/personality-and-preferences", s.cvController.GetPersonalityAndPreference)
space/space/space/space/space/space/space/router/router.go CHANGED
@@ -18,4 +18,5 @@ func (s *Server) setupRoutes() {
18
  // another way to register routes
19
  s.HealthCheckRoute()
20
  s.CVRoute()
 
21
  }
 
18
  // another way to register routes
19
  s.HealthCheckRoute()
20
  s.CVRoute()
21
+ s.StorageRoute()
22
  }
space/space/space/space/space/space/space/router/storage_route.go ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package router
2
+
3
+ import (
4
+ "api.qobiltu.id/middleware"
5
+ "github.com/gin-gonic/gin"
6
+ "net/http"
7
+ "strings"
8
+ )
9
+
10
+ func (s *Server) StorageRoute() {
11
+ s.router.GET("/storage/*filepath", func(ctx *gin.Context) {
12
+ filepath := ctx.Param("filepath")
13
+
14
+ // Prevent directory listing
15
+ if filepath == "" || strings.HasSuffix(filepath, "/") {
16
+ ctx.AbortWithStatus(http.StatusForbidden)
17
+ return
18
+ }
19
+
20
+ fs := http.Dir("uploads")
21
+
22
+ ctx.FileFromFS(filepath, fs)
23
+ }).Use(middleware.AuthUser)
24
+ }
space/space/space/space/space/space/space/services/cv_service.go CHANGED
@@ -1,485 +1,568 @@
1
  package services
2
 
3
  import (
4
- "api.qobiltu.id/models"
5
- "api.qobiltu.id/repositories"
6
- "api.qobiltu.id/response"
7
- "api.qobiltu.id/validation"
8
- "context"
9
- "errors"
10
- "gorm.io/gorm"
 
 
 
11
  )
12
 
13
  type CVService interface {
14
- SaveAccountDetails(ctx context.Context, req *models.AccountDetailsRequest) (*models.AccountDetails, error)
15
- GetAccountDetails(ctx context.Context, id int64) (*models.AccountDetails, error)
16
-
17
- SavePersonalityAndPreference(ctx context.Context, req *models.PersonalityAndPreferenceCVRequest) (*models.PersonalityAndPreferenceCV, error)
18
- GetPersonalityAndPreference(ctx context.Context, id int64) (*models.PersonalityAndPreferenceCV, error)
19
-
20
- CreateFamilyMember(ctx context.Context, req *models.FamilyMemberRequest) (*models.FamilyMemberCV, error)
21
- UpdateFamilyMember(ctx context.Context, id int64, req *models.FamilyMemberRequest) (*models.FamilyMemberCV, error)
22
- ListFamilyMember(ctx context.Context, accountID int64) ([]models.FamilyMemberCV, error)
23
- GetFamilyMember(ctx context.Context, id int64) (*models.FamilyMemberCV, error)
24
- DeleteFamilyMember(ctx context.Context, id int64) error
25
-
26
- SavePhysicalAndHealth(ctx context.Context, req *models.PhysicalAndHealthRequest) (*models.PhysicalAndHealthCV, error)
27
- GetPhysicalAndHealth(ctx context.Context, id int64) (*models.PhysicalAndHealthCV, error)
28
-
29
- SaveWorshipAndReligiousUnderstanding(ctx context.Context, req *models.WorshipAndReligiousUnderstandingRequest) (*models.WorshipAndReligiousUnderstandingCV, error)
30
- GetWorshipAndReligiousUnderstanding(ctx context.Context, id int64) (*models.WorshipAndReligiousUnderstandingCV, error)
31
-
32
- CreateEducation(ctx context.Context, req *models.EducationRequest) (*models.EducationCV, error)
33
- UpdateEducation(ctx context.Context, id int64, req *models.EducationRequest) (*models.EducationCV, error)
34
- ListEducation(ctx context.Context, accountID int64) ([]models.EducationCV, error)
35
- GetEducation(ctx context.Context, id int64) (*models.EducationCV, error)
36
- DeleteEducation(ctx context.Context, id int64) error
37
-
38
- CreateJob(ctx context.Context, req *models.JobRequest) (*models.JobCV, error)
39
- UpdateJob(ctx context.Context, id int64, req *models.JobRequest) (*models.JobCV, error)
40
- ListJob(ctx context.Context, accountID int64) ([]models.JobCV, error)
41
- GetJob(ctx context.Context, id int64) (*models.JobCV, error)
42
- DeleteJob(ctx context.Context, id int64) error
43
-
44
- CreateAchievement(ctx context.Context, req *models.AchievementRequest) (*models.AchievementCV, error)
45
- UpdateAchievement(ctx context.Context, id int64, req *models.AchievementRequest) (*models.AchievementCV, error)
46
- ListAchievement(ctx context.Context, accountID int64) ([]models.AchievementCV, error)
47
- GetAchievement(ctx context.Context, id int64) (*models.AchievementCV, error)
48
- DeleteAchievement(ctx context.Context, id int64) error
 
 
49
  }
50
 
51
  type cvService struct {
52
- cvRepository repositories.CVRepository
 
53
  }
54
 
55
- func NewCVService(cvRepository repositories.CVRepository) CVService {
56
- return &cvService{
57
- cvRepository: cvRepository,
58
- }
 
59
  }
60
 
61
  func (s *cvService) SaveAccountDetails(ctx context.Context, req *models.AccountDetailsRequest) (*models.AccountDetails, error) {
62
- if err := validation.Validate(req); err != nil {
63
- return nil, response.HandleValidationError(err)
64
- }
65
-
66
- // Ambil data lama jika ada
67
- accountDetails, err := s.cvRepository.GetAccountDetailsByAccountID(ctx, req.AccountID)
68
- if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
69
- return nil, response.HandleGormError(err, "Internal Server Error")
70
- }
71
-
72
- // Apply perubahan
73
- if accountDetails == nil {
74
- accountDetails = &models.AccountDetails{}
75
- }
76
-
77
- accountDetails.AccountID = uint(req.AccountID)
78
- accountDetails.FullName = req.FullName
79
- accountDetails.Gender = req.Gender
80
- accountDetails.DateOfBirth = req.DateOfBirth
81
- accountDetails.PlaceOfBirth = req.PlaceOfBirth
82
- accountDetails.Domicile = req.Domicile
83
- accountDetails.MaritalStatus = req.MaritalStatus
84
- accountDetails.LastEducation = req.LastEducation
85
- accountDetails.LastJob = req.LastJob
86
-
87
- // Simpan data
88
- res, err := s.cvRepository.SaveAccountDetails(ctx, accountDetails)
89
- if err != nil {
90
- return nil, response.HandleGormError(err, "Internal Server Error")
91
- }
92
-
93
- return res, nil
 
 
 
 
 
94
  }
95
 
96
  func (s *cvService) GetAccountDetails(ctx context.Context, id int64) (*models.AccountDetails, error) {
97
- res, err := s.cvRepository.GetAccountDetailsByAccountID(ctx, id)
98
- if err != nil {
99
- return nil, response.HandleGormError(err, "Data diri tidak ditemukan")
100
- }
101
- return res, nil
102
  }
103
 
104
  func (s *cvService) SavePersonalityAndPreference(ctx context.Context, req *models.PersonalityAndPreferenceCVRequest) (*models.PersonalityAndPreferenceCV, error) {
105
- if err := validation.Validate(req); err != nil {
106
- return nil, response.HandleValidationError(err)
107
- }
108
-
109
- // Ambil data lama jika ada
110
- personalityAndPreference, err := s.cvRepository.GetPersonalityAndPreferenceByAccountID(ctx, req.AccountID)
111
- if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
112
- return nil, response.HandleGormError(err, "Internal Server Error")
113
- }
114
-
115
- // Apply perubahan
116
- if personalityAndPreference == nil {
117
- personalityAndPreference = &models.PersonalityAndPreferenceCV{}
118
- }
119
-
120
- personalityAndPreference.AccountID = req.AccountID
121
- personalityAndPreference.PositiveTraits = req.PositiveTraits
122
- personalityAndPreference.NegativeTraits = req.NegativeTraits
123
- personalityAndPreference.Hobbies = req.Hobbies
124
- personalityAndPreference.LifeGoals = req.LifeGoals
125
- personalityAndPreference.DailyActivities = req.DailyActivities
126
- personalityAndPreference.LeisureActivities = req.LeisureActivities
127
- personalityAndPreference.Likes = req.Likes
128
- personalityAndPreference.Dislikes = req.Dislikes
129
- personalityAndPreference.StressHandling = req.StressHandling
130
- personalityAndPreference.AngerTriggers = req.AngerTriggers
131
- personalityAndPreference.FavoriteFoodAndDrinks = req.FavoriteFoodAndDrinks
132
- personalityAndPreference.CanCook = req.CanCook
133
- personalityAndPreference.TypesOfDishesCooked = req.TypesOfDishesCooked
134
- personalityAndPreference.MonthlyExpenses = req.MonthlyExpenses
135
-
136
- res, err := s.cvRepository.SavePersonalityAndPreference(ctx, personalityAndPreference)
137
- if err != nil {
138
- return nil, response.HandleGormError(err, "Internal Server Error")
139
- }
140
-
141
- return res, nil
142
  }
143
 
144
  func (s *cvService) GetPersonalityAndPreference(ctx context.Context, id int64) (*models.PersonalityAndPreferenceCV, error) {
145
- res, err := s.cvRepository.GetPersonalityAndPreferenceByAccountID(ctx, id)
146
- if err != nil {
147
- return nil, response.HandleGormError(err, "Internal Server Error")
148
- }
149
 
150
- return res, nil
151
  }
152
 
153
  func (s *cvService) CreateFamilyMember(ctx context.Context, req *models.FamilyMemberRequest) (*models.FamilyMemberCV, error) {
154
- if err := validation.Validate(req); err != nil {
155
- return nil, response.HandleValidationError(err)
156
- }
157
-
158
- // Mapping request ke model
159
- familyMember := &models.FamilyMemberCV{
160
- AccountID: req.AccountID,
161
- Role: req.Role,
162
- Status: req.Status,
163
- Religion: req.Religion,
164
- Job: req.Job,
165
- LastEducation: req.LastEducation,
166
- Age: req.Age,
167
- }
168
-
169
- // Simpan ke repository
170
- res, err := s.cvRepository.SaveFamilyMember(ctx, familyMember)
171
- if err != nil {
172
- return nil, response.HandleGormError(err, "Gagal menyimpan anggota keluarga")
173
- }
174
-
175
- return res, nil
176
  }
177
 
178
  func (s *cvService) ListFamilyMember(ctx context.Context, accountID int64) ([]models.FamilyMemberCV, error) {
179
- list, err := s.cvRepository.ListFamilyMember(ctx, accountID)
180
- if err != nil {
181
- return nil, response.HandleGormError(err, "Gagal mengambil daftar anggota keluarga")
182
- }
183
- return list, nil
184
  }
185
 
186
  func (s *cvService) GetFamilyMember(ctx context.Context, id int64) (*models.FamilyMemberCV, error) {
187
- res, err := s.cvRepository.GetFamilyMember(ctx, id)
188
- if err != nil {
189
- return nil, response.HandleGormError(err, "Data anggota keluarga tidak ditemukan")
190
- }
191
- return res, nil
192
  }
193
 
194
  func (s *cvService) DeleteFamilyMember(ctx context.Context, id int64) error {
195
- err := s.cvRepository.DeleteFamilyMember(ctx, id)
196
- if err != nil {
197
- return response.HandleGormError(err, "Gagal menghapus anggota keluarga")
198
- }
199
- return nil
200
  }
201
 
202
  func (s *cvService) UpdateFamilyMember(ctx context.Context, id int64, req *models.FamilyMemberRequest) (*models.FamilyMemberCV, error) {
203
- if err := validation.Validate(req); err != nil {
204
- return nil, response.HandleValidationError(err)
205
- }
206
-
207
- existing, err := s.cvRepository.GetFamilyMember(ctx, id)
208
- if err != nil {
209
- return nil, response.HandleGormError(err, "Data anggota keluarga tidak ditemukan")
210
- }
211
-
212
- existing.Role = req.Role
213
- existing.Status = req.Status
214
- existing.Religion = req.Religion
215
- existing.Job = req.Job
216
- existing.LastEducation = req.LastEducation
217
- existing.Age = req.Age
218
-
219
- updated, err := s.cvRepository.SaveFamilyMember(ctx, existing)
220
- if err != nil {
221
- return nil, response.HandleGormError(err, "Gagal memperbarui anggota keluarga")
222
- }
223
-
224
- return updated, nil
225
  }
226
 
227
  func (s *cvService) SavePhysicalAndHealth(ctx context.Context, req *models.PhysicalAndHealthRequest) (*models.PhysicalAndHealthCV, error) {
228
- if err := validation.Validate(req); err != nil {
229
- return nil, response.HandleValidationError(err)
230
- }
231
-
232
- // Cek apakah data sudah ada berdasarkan account_id
233
- existing, err := s.cvRepository.GetPhysicalAndHealthByAccountID(ctx, req.AccountID)
234
- if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
235
- return nil, response.HandleGormError(err, "Terjadi kesalahan saat mengambil data fisik dan kesehatan")
236
- }
237
-
238
- // Jika belum ada, buat objek baru
239
- if existing == nil {
240
- existing = &models.PhysicalAndHealthCV{}
241
- }
242
-
243
- // Mapping field dari request
244
- existing.AccountID = req.AccountID
245
- existing.HeightInCm = req.HeightInCm
246
- existing.WeightInKg = req.WeightInKg
247
- existing.BodyShape = req.BodyShape
248
- existing.SkinColor = req.SkinColor
249
- existing.HairType = req.HairType
250
- existing.MedicalHistory = req.MedicalHistory
251
- existing.PhysicalDisorder = req.PhysicalDisorder
252
- existing.PhysicalTraits = req.PhysicalTraits
253
-
254
- // Simpan data
255
- res, err := s.cvRepository.SavePhysicalAndHealth(ctx, existing)
256
- if err != nil {
257
- return nil, response.HandleGormError(err, "Gagal menyimpan data fisik dan kesehatan")
258
- }
259
-
260
- return res, nil
261
  }
262
 
263
  func (s *cvService) GetPhysicalAndHealth(ctx context.Context, id int64) (*models.PhysicalAndHealthCV, error) {
264
- res, err := s.cvRepository.GetPhysicalAndHealthByAccountID(ctx, id)
265
- if err != nil {
266
- return nil, response.HandleGormError(err, "Data fisik dan kesehatan tidak ditemukan")
267
- }
268
- return res, nil
269
  }
270
 
271
  func (s *cvService) SaveWorshipAndReligiousUnderstanding(ctx context.Context, req *models.WorshipAndReligiousUnderstandingRequest) (*models.WorshipAndReligiousUnderstandingCV, error) {
272
- if err := validation.Validate(req); err != nil {
273
- return nil, response.HandleValidationError(err)
274
- }
275
-
276
- // Cek apakah data sudah ada berdasarkan account_id
277
- worshipAndReligiousUnderstanding, err := s.cvRepository.GetWorshipAndReligiousUnderstandingByAccountID(ctx, req.AccountID)
278
- if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
279
- return nil, response.HandleGormError(err, "Terjadi kesalahan saat mengambil data agama dan pemahaman agama")
280
- }
281
-
282
- // Jika belum ada, buat objek baru
283
- if worshipAndReligiousUnderstanding == nil {
284
- worshipAndReligiousUnderstanding = &models.WorshipAndReligiousUnderstandingCV{}
285
- }
286
-
287
- // Mapping field dari request
288
- worshipAndReligiousUnderstanding.AccountID = req.AccountID
289
- worshipAndReligiousUnderstanding.ObligatoryPrayer = req.ObligatoryPrayer
290
- worshipAndReligiousUnderstanding.CongregationalPrayer = req.CongregationalPrayer
291
- worshipAndReligiousUnderstanding.TahajjudPrayer = req.TahajjudPrayer
292
- worshipAndReligiousUnderstanding.DhuhaPrayer = req.DhuhaPrayer
293
- worshipAndReligiousUnderstanding.QuranMemorization = req.QuranMemorization
294
- worshipAndReligiousUnderstanding.QuranReadingAbility = req.QuranReadingAbility
295
- worshipAndReligiousUnderstanding.DaudFasting = req.DaudFasting
296
- worshipAndReligiousUnderstanding.AyyamulBidhFasting = req.AyyamulBidhFasting
297
- worshipAndReligiousUnderstanding.HajjOrUmrah = req.HajjOrUmrah
298
- worshipAndReligiousUnderstanding.ListeningToMusic = req.ListeningToMusic
299
- worshipAndReligiousUnderstanding.OpinionOnIkhtilat = req.OpinionOnIkhtilat
300
- worshipAndReligiousUnderstanding.OpinionOnTouchingNonMahram = req.OpinionOnTouchingNonMahram
301
- worshipAndReligiousUnderstanding.OpinionOnVeil = req.OpinionOnVeil
302
- worshipAndReligiousUnderstanding.WeeklyReligiousStudies = req.WeeklyReligiousStudies
303
- worshipAndReligiousUnderstanding.FollowedUstadz = req.FollowedUstadz
304
-
305
- // Simpan data
306
- res, err := s.cvRepository.SaveWorshipAndReligiousUnderstanding(ctx, worshipAndReligiousUnderstanding)
307
- if err != nil {
308
- return nil, response.HandleGormError(err, "Internal Server Error")
309
- }
310
-
311
- return res, nil
312
  }
313
 
314
  func (s *cvService) GetWorshipAndReligiousUnderstanding(ctx context.Context, id int64) (*models.WorshipAndReligiousUnderstandingCV, error) {
315
- res, err := s.cvRepository.GetWorshipAndReligiousUnderstandingByAccountID(ctx, id)
316
- if err != nil {
317
- return nil, response.HandleGormError(err, "Data agama dan pemahaman agama tidak ditemukan")
318
- }
319
- return res, nil
320
  }
321
 
322
  func (s *cvService) CreateEducation(ctx context.Context, req *models.EducationRequest) (*models.EducationCV, error) {
323
- if err := validation.Validate(req); err != nil {
324
- return nil, response.HandleValidationError(err)
325
- }
326
-
327
- edu := &models.EducationCV{
328
- AccountID: req.AccountID,
329
- LastEducation: req.LastEducation,
330
- EducationInstitute: req.EducationInstitute,
331
- EducationMajor: req.EducationMajor,
332
- YearStart: req.YearStart,
333
- YearGraduate: req.YearGraduate,
334
- }
335
-
336
- res, err := s.cvRepository.SaveEducation(ctx, edu)
337
- if err != nil {
338
- return nil, response.HandleGormError(err, "Gagal menambahkan data pendidikan")
339
- }
340
-
341
- return res, nil
342
  }
343
 
344
  func (s *cvService) UpdateEducation(ctx context.Context, id int64, req *models.EducationRequest) (*models.EducationCV, error) {
345
- if err := validation.Validate(req); err != nil {
346
- return nil, response.HandleValidationError(err)
347
- }
348
-
349
- edu, err := s.cvRepository.GetEducation(ctx, id)
350
- if err != nil {
351
- return nil, response.HandleGormError(err, "Data pendidikan tidak ditemukan")
352
- }
353
-
354
- edu.LastEducation = req.LastEducation
355
- edu.EducationInstitute = req.EducationInstitute
356
- edu.EducationMajor = req.EducationMajor
357
- edu.YearStart = req.YearStart
358
- edu.YearGraduate = req.YearGraduate
359
-
360
- res, err := s.cvRepository.SaveEducation(ctx, edu)
361
- if err != nil {
362
- return nil, response.HandleGormError(err, "Gagal memperbarui data pendidikan")
363
- }
364
- return res, nil
365
  }
366
 
367
  func (s *cvService) ListEducation(ctx context.Context, accountID int64) ([]models.EducationCV, error) {
368
- return s.cvRepository.ListEducation(ctx, accountID)
369
  }
370
 
371
  func (s *cvService) GetEducation(ctx context.Context, id int64) (*models.EducationCV, error) {
372
- edu, err := s.cvRepository.GetEducation(ctx, id)
373
- if err != nil {
374
- return nil, response.HandleGormError(err, "Data pendidikan tidak ditemukan")
375
- }
376
- return edu, nil
377
  }
378
 
379
  func (s *cvService) DeleteEducation(ctx context.Context, id int64) error {
380
- return s.cvRepository.DeleteEducation(ctx, id)
381
  }
382
 
383
  func (s *cvService) CreateJob(ctx context.Context, req *models.JobRequest) (*models.JobCV, error) {
384
- if err := validation.Validate(req); err != nil {
385
- return nil, response.HandleValidationError(err)
386
- }
387
-
388
- job := &models.JobCV{
389
- AccountID: req.AccountID,
390
- InstitutionName: req.InstitutionName,
391
- CurrentJob: req.CurrentJob,
392
- YearStartedWorking: req.YearStartedWorking,
393
- MonthlyIncome: req.MonthlyIncome,
394
- IncomeSources: req.IncomeSources,
395
- }
396
- res, err := s.cvRepository.SaveJob(ctx, job)
397
- if err != nil {
398
- return nil, response.HandleGormError(err, "Gagal menambahkan data pekerjaan")
399
- }
400
- return res, nil
401
  }
402
 
403
  func (s *cvService) UpdateJob(ctx context.Context, id int64, req *models.JobRequest) (*models.JobCV, error) {
404
- if err := validation.Validate(req); err != nil {
405
- return nil, response.HandleValidationError(err)
406
- }
407
-
408
- job, err := s.cvRepository.GetJob(ctx, id)
409
- if err != nil {
410
- return nil, response.HandleGormError(err, "Data pekerjaan tidak ditemukan")
411
- }
412
-
413
- job.InstitutionName = req.InstitutionName
414
- job.CurrentJob = req.CurrentJob
415
- job.YearStartedWorking = req.YearStartedWorking
416
- job.MonthlyIncome = req.MonthlyIncome
417
- job.IncomeSources = req.IncomeSources
418
-
419
- res, err := s.cvRepository.SaveJob(ctx, job)
420
- if err != nil {
421
- return nil, response.HandleGormError(err, "Gagal memperbarui data pekerjaan")
422
- }
423
- return res, nil
424
  }
425
 
426
  func (s *cvService) ListJob(ctx context.Context, accountID int64) ([]models.JobCV, error) {
427
- return s.cvRepository.ListJob(ctx, accountID)
428
  }
429
 
430
  func (s *cvService) GetJob(ctx context.Context, id int64) (*models.JobCV, error) {
431
- job, err := s.cvRepository.GetJob(ctx, id)
432
- if err != nil {
433
- return nil, response.HandleGormError(err, "Data pekerjaan tidak ditemukan")
434
- }
435
- return job, nil
436
  }
437
 
438
  func (s *cvService) DeleteJob(ctx context.Context, id int64) error {
439
- return s.cvRepository.DeleteJob(ctx, id)
440
  }
441
 
442
  func (s *cvService) CreateAchievement(ctx context.Context, req *models.AchievementRequest) (*models.AchievementCV, error) {
443
- ach := &models.AchievementCV{
444
- AccountID: req.AccountID,
445
- AchievementOrAward: req.AchievementOrAward,
446
- }
447
- res, err := s.cvRepository.SaveAchievement(ctx, ach)
448
- if err != nil {
449
- return nil, response.HandleGormError(err, "Gagal menambahkan data prestasi")
450
- }
451
-
452
- return res, nil
453
  }
454
 
455
  func (s *cvService) UpdateAchievement(ctx context.Context, id int64, req *models.AchievementRequest) (*models.AchievementCV, error) {
456
- ach, err := s.cvRepository.GetAchievement(ctx, id)
457
- if err != nil {
458
- return nil, response.HandleGormError(err, "Data prestasi tidak ditemukan")
459
- }
460
 
461
- ach.AchievementOrAward = req.AchievementOrAward
462
 
463
- res, err := s.cvRepository.SaveAchievement(ctx, ach)
464
- if err != nil {
465
- return nil, response.HandleGormError(err, "Gagal memperbarui data prestasi")
466
- }
467
 
468
- return res, nil
469
  }
470
 
471
  func (s *cvService) ListAchievement(ctx context.Context, accountID int64) ([]models.AchievementCV, error) {
472
- return s.cvRepository.ListAchievement(ctx, accountID)
473
  }
474
 
475
  func (s *cvService) GetAchievement(ctx context.Context, id int64) (*models.AchievementCV, error) {
476
- ach, err := s.cvRepository.GetAchievement(ctx, id)
477
- if err != nil {
478
- return nil, response.HandleGormError(err, "Data prestasi tidak ditemukan")
479
- }
480
- return ach, nil
481
  }
482
 
483
  func (s *cvService) DeleteAchievement(ctx context.Context, id int64) error {
484
- return s.cvRepository.DeleteAchievement(ctx, id)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
485
  }
 
1
  package services
2
 
3
  import (
4
+ "context"
5
+ "errors"
6
+ "strconv"
7
+
8
+ "api.qobiltu.id/models"
9
+ "api.qobiltu.id/pkg/storage"
10
+ "api.qobiltu.id/pkg/validation"
11
+ "api.qobiltu.id/repositories"
12
+ "api.qobiltu.id/response"
13
+ "gorm.io/gorm"
14
  )
15
 
16
  type CVService interface {
17
+ SaveAccountDetails(ctx context.Context, req *models.AccountDetailsRequest) (*models.AccountDetails, error)
18
+ GetAccountDetails(ctx context.Context, id int64) (*models.AccountDetails, error)
19
+
20
+ SavePersonalityAndPreference(ctx context.Context, req *models.PersonalityAndPreferenceCVRequest) (*models.PersonalityAndPreferenceCV, error)
21
+ GetPersonalityAndPreference(ctx context.Context, id int64) (*models.PersonalityAndPreferenceCV, error)
22
+
23
+ CreateFamilyMember(ctx context.Context, req *models.FamilyMemberRequest) (*models.FamilyMemberCV, error)
24
+ UpdateFamilyMember(ctx context.Context, id int64, req *models.FamilyMemberRequest) (*models.FamilyMemberCV, error)
25
+ ListFamilyMember(ctx context.Context, accountID int64) ([]models.FamilyMemberCV, error)
26
+ GetFamilyMember(ctx context.Context, id int64) (*models.FamilyMemberCV, error)
27
+ DeleteFamilyMember(ctx context.Context, id int64) error
28
+
29
+ SavePhysicalAndHealth(ctx context.Context, req *models.PhysicalAndHealthRequest) (*models.PhysicalAndHealthCV, error)
30
+ GetPhysicalAndHealth(ctx context.Context, id int64) (*models.PhysicalAndHealthCV, error)
31
+
32
+ SaveWorshipAndReligiousUnderstanding(ctx context.Context, req *models.WorshipAndReligiousUnderstandingRequest) (*models.WorshipAndReligiousUnderstandingCV, error)
33
+ GetWorshipAndReligiousUnderstanding(ctx context.Context, id int64) (*models.WorshipAndReligiousUnderstandingCV, error)
34
+
35
+ CreateEducation(ctx context.Context, req *models.EducationRequest) (*models.EducationCV, error)
36
+ UpdateEducation(ctx context.Context, id int64, req *models.EducationRequest) (*models.EducationCV, error)
37
+ ListEducation(ctx context.Context, accountID int64) ([]models.EducationCV, error)
38
+ GetEducation(ctx context.Context, id int64) (*models.EducationCV, error)
39
+ DeleteEducation(ctx context.Context, id int64) error
40
+
41
+ CreateJob(ctx context.Context, req *models.JobRequest) (*models.JobCV, error)
42
+ UpdateJob(ctx context.Context, id int64, req *models.JobRequest) (*models.JobCV, error)
43
+ ListJob(ctx context.Context, accountID int64) ([]models.JobCV, error)
44
+ GetJob(ctx context.Context, id int64) (*models.JobCV, error)
45
+ DeleteJob(ctx context.Context, id int64) error
46
+
47
+ CreateAchievement(ctx context.Context, req *models.AchievementRequest) (*models.AchievementCV, error)
48
+ UpdateAchievement(ctx context.Context, id int64, req *models.AchievementRequest) (*models.AchievementCV, error)
49
+ ListAchievement(ctx context.Context, accountID int64) ([]models.AchievementCV, error)
50
+ GetAchievement(ctx context.Context, id int64) (*models.AchievementCV, error)
51
+ DeleteAchievement(ctx context.Context, id int64) error
52
+
53
+ UploadProfileImage(ctx context.Context, req *models.UploadProfileImageRequest) (*models.UploadProfileImageResponse, error)
54
  }
55
 
56
  type cvService struct {
57
+ cvRepository repositories.CVRepository
58
+ storage storage.Storage
59
  }
60
 
61
+ func NewCVService(cvRepository repositories.CVRepository, storage storage.Storage) CVService {
62
+ return &cvService{
63
+ cvRepository: cvRepository,
64
+ storage: storage,
65
+ }
66
  }
67
 
68
  func (s *cvService) SaveAccountDetails(ctx context.Context, req *models.AccountDetailsRequest) (*models.AccountDetails, error) {
69
+ if err := validation.Validate(req); err != nil {
70
+ return nil, response.HandleValidationError(err)
71
+ }
72
+
73
+ // Ambil data lama jika ada
74
+ accountDetails, err := s.cvRepository.GetAccountDetailsByAccountID(ctx, req.AccountID)
75
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
76
+ return nil, response.HandleGormError(err, "Internal Server Error")
77
+ }
78
+
79
+ // Apply perubahan
80
+ if accountDetails == nil {
81
+ accountDetails = &models.AccountDetails{}
82
+ }
83
+
84
+ accountDetails.AccountID = uint(req.AccountID)
85
+ accountDetails.FullName = req.FullName
86
+ accountDetails.Gender = req.Gender
87
+ accountDetails.DateOfBirth = req.DateOfBirth
88
+ accountDetails.PlaceOfBirth = req.PlaceOfBirth
89
+ accountDetails.Domicile = req.Domicile
90
+ accountDetails.MaritalStatus = req.MaritalStatus
91
+ accountDetails.LastEducation = req.LastEducation
92
+ accountDetails.LastJob = req.LastJob
93
+
94
+ if req.PhoneNumber != nil {
95
+ sanitizedPhone := validation.SanitizePhoneNumber(*req.PhoneNumber)
96
+ accountDetails.PhoneNumber = &sanitizedPhone
97
+ }
98
+
99
+ // Simpan data
100
+ res, err := s.cvRepository.SaveAccountDetails(ctx, accountDetails)
101
+ if err != nil {
102
+ return nil, response.HandleGormError(err, "Internal Server Error")
103
+ }
104
+
105
+ return res, nil
106
  }
107
 
108
  func (s *cvService) GetAccountDetails(ctx context.Context, id int64) (*models.AccountDetails, error) {
109
+ res, err := s.cvRepository.GetAccountDetailsByAccountID(ctx, id)
110
+ if err != nil {
111
+ return nil, response.HandleGormError(err, "Data diri tidak ditemukan")
112
+ }
113
+ return res, nil
114
  }
115
 
116
  func (s *cvService) SavePersonalityAndPreference(ctx context.Context, req *models.PersonalityAndPreferenceCVRequest) (*models.PersonalityAndPreferenceCV, error) {
117
+ if err := validation.Validate(req); err != nil {
118
+ return nil, response.HandleValidationError(err)
119
+ }
120
+
121
+ // Ambil data lama jika ada
122
+ personalityAndPreference, err := s.cvRepository.GetPersonalityAndPreferenceByAccountID(ctx, req.AccountID)
123
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
124
+ return nil, response.HandleGormError(err, "Internal Server Error")
125
+ }
126
+
127
+ // Apply perubahan
128
+ if personalityAndPreference == nil {
129
+ personalityAndPreference = &models.PersonalityAndPreferenceCV{}
130
+ }
131
+
132
+ personalityAndPreference.AccountID = req.AccountID
133
+ personalityAndPreference.PositiveTraits = req.PositiveTraits
134
+ personalityAndPreference.NegativeTraits = req.NegativeTraits
135
+ personalityAndPreference.Hobbies = req.Hobbies
136
+ personalityAndPreference.LifeGoals = req.LifeGoals
137
+ personalityAndPreference.DailyActivities = req.DailyActivities
138
+ personalityAndPreference.LeisureActivities = req.LeisureActivities
139
+ personalityAndPreference.Likes = req.Likes
140
+ personalityAndPreference.Dislikes = req.Dislikes
141
+ personalityAndPreference.StressHandling = req.StressHandling
142
+ personalityAndPreference.AngerTriggers = req.AngerTriggers
143
+ personalityAndPreference.FavoriteFoodAndDrinks = req.FavoriteFoodAndDrinks
144
+ personalityAndPreference.CanCook = req.CanCook
145
+ personalityAndPreference.TypesOfDishesCooked = req.TypesOfDishesCooked
146
+ personalityAndPreference.MonthlyExpenses = req.MonthlyExpenses
147
+
148
+ res, err := s.cvRepository.SavePersonalityAndPreference(ctx, personalityAndPreference)
149
+ if err != nil {
150
+ return nil, response.HandleGormError(err, "Internal Server Error")
151
+ }
152
+
153
+ return res, nil
154
  }
155
 
156
  func (s *cvService) GetPersonalityAndPreference(ctx context.Context, id int64) (*models.PersonalityAndPreferenceCV, error) {
157
+ res, err := s.cvRepository.GetPersonalityAndPreferenceByAccountID(ctx, id)
158
+ if err != nil {
159
+ return nil, response.HandleGormError(err, "Internal Server Error")
160
+ }
161
 
162
+ return res, nil
163
  }
164
 
165
  func (s *cvService) CreateFamilyMember(ctx context.Context, req *models.FamilyMemberRequest) (*models.FamilyMemberCV, error) {
166
+ if err := validation.Validate(req); err != nil {
167
+ return nil, response.HandleValidationError(err)
168
+ }
169
+
170
+ // Mapping request ke model
171
+ familyMember := &models.FamilyMemberCV{
172
+ AccountID: req.AccountID,
173
+ Role: req.Role,
174
+ Status: req.Status,
175
+ Religion: req.Religion,
176
+ Job: req.Job,
177
+ LastEducation: req.LastEducation,
178
+ Age: req.Age,
179
+ }
180
+
181
+ // Simpan ke repository
182
+ res, err := s.cvRepository.SaveFamilyMember(ctx, familyMember)
183
+ if err != nil {
184
+ return nil, response.HandleGormError(err, "Gagal menyimpan anggota keluarga")
185
+ }
186
+
187
+ return res, nil
188
  }
189
 
190
  func (s *cvService) ListFamilyMember(ctx context.Context, accountID int64) ([]models.FamilyMemberCV, error) {
191
+ list, err := s.cvRepository.ListFamilyMember(ctx, accountID)
192
+ if err != nil {
193
+ return nil, response.HandleGormError(err, "Gagal mengambil daftar anggota keluarga")
194
+ }
195
+ return list, nil
196
  }
197
 
198
  func (s *cvService) GetFamilyMember(ctx context.Context, id int64) (*models.FamilyMemberCV, error) {
199
+ res, err := s.cvRepository.GetFamilyMember(ctx, id)
200
+ if err != nil {
201
+ return nil, response.HandleGormError(err, "Data anggota keluarga tidak ditemukan")
202
+ }
203
+ return res, nil
204
  }
205
 
206
  func (s *cvService) DeleteFamilyMember(ctx context.Context, id int64) error {
207
+ err := s.cvRepository.DeleteFamilyMember(ctx, id)
208
+ if err != nil {
209
+ return response.HandleGormError(err, "Gagal menghapus anggota keluarga")
210
+ }
211
+ return nil
212
  }
213
 
214
  func (s *cvService) UpdateFamilyMember(ctx context.Context, id int64, req *models.FamilyMemberRequest) (*models.FamilyMemberCV, error) {
215
+ if err := validation.Validate(req); err != nil {
216
+ return nil, response.HandleValidationError(err)
217
+ }
218
+
219
+ existing, err := s.cvRepository.GetFamilyMember(ctx, id)
220
+ if err != nil {
221
+ return nil, response.HandleGormError(err, "Data anggota keluarga tidak ditemukan")
222
+ }
223
+
224
+ existing.Role = req.Role
225
+ existing.Status = req.Status
226
+ existing.Religion = req.Religion
227
+ existing.Job = req.Job
228
+ existing.LastEducation = req.LastEducation
229
+ existing.Age = req.Age
230
+
231
+ updated, err := s.cvRepository.SaveFamilyMember(ctx, existing)
232
+ if err != nil {
233
+ return nil, response.HandleGormError(err, "Gagal memperbarui anggota keluarga")
234
+ }
235
+
236
+ return updated, nil
237
  }
238
 
239
  func (s *cvService) SavePhysicalAndHealth(ctx context.Context, req *models.PhysicalAndHealthRequest) (*models.PhysicalAndHealthCV, error) {
240
+ if err := validation.Validate(req); err != nil {
241
+ return nil, response.HandleValidationError(err)
242
+ }
243
+
244
+ // Cek apakah data sudah ada berdasarkan account_id
245
+ existing, err := s.cvRepository.GetPhysicalAndHealthByAccountID(ctx, req.AccountID)
246
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
247
+ return nil, response.HandleGormError(err, "Terjadi kesalahan saat mengambil data fisik dan kesehatan")
248
+ }
249
+
250
+ // Jika belum ada, buat objek baru
251
+ if existing == nil {
252
+ existing = &models.PhysicalAndHealthCV{}
253
+ }
254
+
255
+ // Mapping field dari request
256
+ existing.AccountID = req.AccountID
257
+ existing.HeightInCm = req.HeightInCm
258
+ existing.WeightInKg = req.WeightInKg
259
+ existing.BodyShape = req.BodyShape
260
+ existing.SkinColor = req.SkinColor
261
+ existing.HairType = req.HairType
262
+ existing.MedicalHistory = req.MedicalHistory
263
+ existing.PhysicalDisorder = req.PhysicalDisorder
264
+ existing.PhysicalTraits = req.PhysicalTraits
265
+
266
+ // Simpan data
267
+ res, err := s.cvRepository.SavePhysicalAndHealth(ctx, existing)
268
+ if err != nil {
269
+ return nil, response.HandleGormError(err, "Gagal menyimpan data fisik dan kesehatan")
270
+ }
271
+
272
+ return res, nil
273
  }
274
 
275
  func (s *cvService) GetPhysicalAndHealth(ctx context.Context, id int64) (*models.PhysicalAndHealthCV, error) {
276
+ res, err := s.cvRepository.GetPhysicalAndHealthByAccountID(ctx, id)
277
+ if err != nil {
278
+ return nil, response.HandleGormError(err, "Data fisik dan kesehatan tidak ditemukan")
279
+ }
280
+ return res, nil
281
  }
282
 
283
  func (s *cvService) SaveWorshipAndReligiousUnderstanding(ctx context.Context, req *models.WorshipAndReligiousUnderstandingRequest) (*models.WorshipAndReligiousUnderstandingCV, error) {
284
+ if err := validation.Validate(req); err != nil {
285
+ return nil, response.HandleValidationError(err)
286
+ }
287
+
288
+ // Cek apakah data sudah ada berdasarkan account_id
289
+ worshipAndReligiousUnderstanding, err := s.cvRepository.GetWorshipAndReligiousUnderstandingByAccountID(ctx, req.AccountID)
290
+ if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
291
+ return nil, response.HandleGormError(err, "Terjadi kesalahan saat mengambil data agama dan pemahaman agama")
292
+ }
293
+
294
+ // Jika belum ada, buat objek baru
295
+ if worshipAndReligiousUnderstanding == nil {
296
+ worshipAndReligiousUnderstanding = &models.WorshipAndReligiousUnderstandingCV{}
297
+ }
298
+
299
+ // Mapping field dari request
300
+ worshipAndReligiousUnderstanding.AccountID = req.AccountID
301
+ worshipAndReligiousUnderstanding.ObligatoryPrayer = req.ObligatoryPrayer
302
+ worshipAndReligiousUnderstanding.CongregationalPrayer = req.CongregationalPrayer
303
+ worshipAndReligiousUnderstanding.TahajjudPrayer = req.TahajjudPrayer
304
+ worshipAndReligiousUnderstanding.DhuhaPrayer = req.DhuhaPrayer
305
+ worshipAndReligiousUnderstanding.QuranMemorization = req.QuranMemorization
306
+ worshipAndReligiousUnderstanding.QuranReadingAbility = req.QuranReadingAbility
307
+ worshipAndReligiousUnderstanding.DaudFasting = req.DaudFasting
308
+ worshipAndReligiousUnderstanding.AyyamulBidhFasting = req.AyyamulBidhFasting
309
+ worshipAndReligiousUnderstanding.HajjOrUmrah = req.HajjOrUmrah
310
+ worshipAndReligiousUnderstanding.ListeningToMusic = req.ListeningToMusic
311
+ worshipAndReligiousUnderstanding.OpinionOnIkhtilat = req.OpinionOnIkhtilat
312
+ worshipAndReligiousUnderstanding.OpinionOnTouchingNonMahram = req.OpinionOnTouchingNonMahram
313
+ worshipAndReligiousUnderstanding.OpinionOnVeil = req.OpinionOnVeil
314
+ worshipAndReligiousUnderstanding.WeeklyReligiousStudies = req.WeeklyReligiousStudies
315
+ worshipAndReligiousUnderstanding.FollowedUstadz = req.FollowedUstadz
316
+
317
+ // Simpan data
318
+ res, err := s.cvRepository.SaveWorshipAndReligiousUnderstanding(ctx, worshipAndReligiousUnderstanding)
319
+ if err != nil {
320
+ return nil, response.HandleGormError(err, "Internal Server Error")
321
+ }
322
+
323
+ return res, nil
324
  }
325
 
326
  func (s *cvService) GetWorshipAndReligiousUnderstanding(ctx context.Context, id int64) (*models.WorshipAndReligiousUnderstandingCV, error) {
327
+ res, err := s.cvRepository.GetWorshipAndReligiousUnderstandingByAccountID(ctx, id)
328
+ if err != nil {
329
+ return nil, response.HandleGormError(err, "Data agama dan pemahaman agama tidak ditemukan")
330
+ }
331
+ return res, nil
332
  }
333
 
334
  func (s *cvService) CreateEducation(ctx context.Context, req *models.EducationRequest) (*models.EducationCV, error) {
335
+ if err := validation.Validate(req); err != nil {
336
+ return nil, response.HandleValidationError(err)
337
+ }
338
+
339
+ edu := &models.EducationCV{
340
+ AccountID: req.AccountID,
341
+ LastEducation: req.LastEducation,
342
+ EducationInstitute: req.EducationInstitute,
343
+ EducationMajor: req.EducationMajor,
344
+ YearStart: req.YearStart,
345
+ YearGraduate: req.YearGraduate,
346
+ }
347
+
348
+ res, err := s.cvRepository.SaveEducation(ctx, edu)
349
+ if err != nil {
350
+ return nil, response.HandleGormError(err, "Gagal menambahkan data pendidikan")
351
+ }
352
+
353
+ return res, nil
354
  }
355
 
356
  func (s *cvService) UpdateEducation(ctx context.Context, id int64, req *models.EducationRequest) (*models.EducationCV, error) {
357
+ if err := validation.Validate(req); err != nil {
358
+ return nil, response.HandleValidationError(err)
359
+ }
360
+
361
+ edu, err := s.cvRepository.GetEducation(ctx, id)
362
+ if err != nil {
363
+ return nil, response.HandleGormError(err, "Data pendidikan tidak ditemukan")
364
+ }
365
+
366
+ edu.LastEducation = req.LastEducation
367
+ edu.EducationInstitute = req.EducationInstitute
368
+ edu.EducationMajor = req.EducationMajor
369
+ edu.YearStart = req.YearStart
370
+ edu.YearGraduate = req.YearGraduate
371
+
372
+ res, err := s.cvRepository.SaveEducation(ctx, edu)
373
+ if err != nil {
374
+ return nil, response.HandleGormError(err, "Gagal memperbarui data pendidikan")
375
+ }
376
+ return res, nil
377
  }
378
 
379
  func (s *cvService) ListEducation(ctx context.Context, accountID int64) ([]models.EducationCV, error) {
380
+ return s.cvRepository.ListEducation(ctx, accountID)
381
  }
382
 
383
  func (s *cvService) GetEducation(ctx context.Context, id int64) (*models.EducationCV, error) {
384
+ edu, err := s.cvRepository.GetEducation(ctx, id)
385
+ if err != nil {
386
+ return nil, response.HandleGormError(err, "Data pendidikan tidak ditemukan")
387
+ }
388
+ return edu, nil
389
  }
390
 
391
  func (s *cvService) DeleteEducation(ctx context.Context, id int64) error {
392
+ return s.cvRepository.DeleteEducation(ctx, id)
393
  }
394
 
395
  func (s *cvService) CreateJob(ctx context.Context, req *models.JobRequest) (*models.JobCV, error) {
396
+ if err := validation.Validate(req); err != nil {
397
+ return nil, response.HandleValidationError(err)
398
+ }
399
+
400
+ job := &models.JobCV{
401
+ AccountID: req.AccountID,
402
+ InstitutionName: req.InstitutionName,
403
+ CurrentJob: req.CurrentJob,
404
+ YearStartedWorking: req.YearStartedWorking,
405
+ MonthlyIncome: req.MonthlyIncome,
406
+ IncomeSources: req.IncomeSources,
407
+ }
408
+ res, err := s.cvRepository.SaveJob(ctx, job)
409
+ if err != nil {
410
+ return nil, response.HandleGormError(err, "Gagal menambahkan data pekerjaan")
411
+ }
412
+ return res, nil
413
  }
414
 
415
  func (s *cvService) UpdateJob(ctx context.Context, id int64, req *models.JobRequest) (*models.JobCV, error) {
416
+ if err := validation.Validate(req); err != nil {
417
+ return nil, response.HandleValidationError(err)
418
+ }
419
+
420
+ job, err := s.cvRepository.GetJob(ctx, id)
421
+ if err != nil {
422
+ return nil, response.HandleGormError(err, "Data pekerjaan tidak ditemukan")
423
+ }
424
+
425
+ job.InstitutionName = req.InstitutionName
426
+ job.CurrentJob = req.CurrentJob
427
+ job.YearStartedWorking = req.YearStartedWorking
428
+ job.MonthlyIncome = req.MonthlyIncome
429
+ job.IncomeSources = req.IncomeSources
430
+
431
+ res, err := s.cvRepository.SaveJob(ctx, job)
432
+ if err != nil {
433
+ return nil, response.HandleGormError(err, "Gagal memperbarui data pekerjaan")
434
+ }
435
+ return res, nil
436
  }
437
 
438
  func (s *cvService) ListJob(ctx context.Context, accountID int64) ([]models.JobCV, error) {
439
+ return s.cvRepository.ListJob(ctx, accountID)
440
  }
441
 
442
  func (s *cvService) GetJob(ctx context.Context, id int64) (*models.JobCV, error) {
443
+ job, err := s.cvRepository.GetJob(ctx, id)
444
+ if err != nil {
445
+ return nil, response.HandleGormError(err, "Data pekerjaan tidak ditemukan")
446
+ }
447
+ return job, nil
448
  }
449
 
450
  func (s *cvService) DeleteJob(ctx context.Context, id int64) error {
451
+ return s.cvRepository.DeleteJob(ctx, id)
452
  }
453
 
454
  func (s *cvService) CreateAchievement(ctx context.Context, req *models.AchievementRequest) (*models.AchievementCV, error) {
455
+ ach := &models.AchievementCV{
456
+ AccountID: req.AccountID,
457
+ AchievementOrAward: req.AchievementOrAward,
458
+ }
459
+ res, err := s.cvRepository.SaveAchievement(ctx, ach)
460
+ if err != nil {
461
+ return nil, response.HandleGormError(err, "Gagal menambahkan data prestasi")
462
+ }
463
+
464
+ return res, nil
465
  }
466
 
467
  func (s *cvService) UpdateAchievement(ctx context.Context, id int64, req *models.AchievementRequest) (*models.AchievementCV, error) {
468
+ ach, err := s.cvRepository.GetAchievement(ctx, id)
469
+ if err != nil {
470
+ return nil, response.HandleGormError(err, "Data prestasi tidak ditemukan")
471
+ }
472
 
473
+ ach.AchievementOrAward = req.AchievementOrAward
474
 
475
+ res, err := s.cvRepository.SaveAchievement(ctx, ach)
476
+ if err != nil {
477
+ return nil, response.HandleGormError(err, "Gagal memperbarui data prestasi")
478
+ }
479
 
480
+ return res, nil
481
  }
482
 
483
  func (s *cvService) ListAchievement(ctx context.Context, accountID int64) ([]models.AchievementCV, error) {
484
+ return s.cvRepository.ListAchievement(ctx, accountID)
485
  }
486
 
487
  func (s *cvService) GetAchievement(ctx context.Context, id int64) (*models.AchievementCV, error) {
488
+ ach, err := s.cvRepository.GetAchievement(ctx, id)
489
+ if err != nil {
490
+ return nil, response.HandleGormError(err, "Data prestasi tidak ditemukan")
491
+ }
492
+ return ach, nil
493
  }
494
 
495
  func (s *cvService) DeleteAchievement(ctx context.Context, id int64) error {
496
+ return s.cvRepository.DeleteAchievement(ctx, id)
497
+ }
498
+
499
+ func (s *cvService) UploadProfileImage(ctx context.Context, req *models.UploadProfileImageRequest) (*models.UploadProfileImageResponse, error) {
500
+ if req.File == nil {
501
+ return nil, models.Exception{
502
+ BadRequest: true,
503
+ Message: "No file uploaded",
504
+ Err: storage.ErrNoFileUploaded,
505
+ }
506
+ }
507
+
508
+ var maxFileSize int64 = 5 << 20 // 5MB
509
+
510
+ if req.File.Size > maxFileSize {
511
+ return nil, models.Exception{
512
+ BadRequest: true,
513
+ Message: "File too large, max size is 5MB",
514
+ Err: storage.ErrFileTooLarge,
515
+ }
516
+ }
517
+
518
+ if !s.storage.ValidateExtension(storage.ProfileImage, req.File.Filename) {
519
+ return nil, models.Exception{
520
+ BadRequest: true,
521
+ Message: "Invalid file extension",
522
+ Err: storage.ErrInvalidFileType,
523
+ }
524
+ }
525
+
526
+ accountDetails, err := s.cvRepository.GetAccountDetailsByAccountID(ctx, req.AccountID)
527
+ if err != nil {
528
+ return nil, response.HandleGormError(err, "Internal Server Error")
529
+ }
530
+
531
+ filename := s.storage.GenerateFilename(req.File.Filename)
532
+ path := s.storage.GetPath(storage.ProfileImage, strconv.Itoa(int(req.AccountID)), filename)
533
+
534
+ file, err := req.File.Open()
535
+ if err != nil {
536
+ return nil, models.Exception{
537
+ InternalServerError: true,
538
+ Message: "Internal Server Error",
539
+ Err: err,
540
+ }
541
+ }
542
+ defer file.Close()
543
+
544
+ if err := s.storage.Upload(ctx, file, path); err != nil {
545
+ return nil, models.Exception{
546
+ InternalServerError: true,
547
+ Message: "Internal Server Error",
548
+ Err: err,
549
+ }
550
+ }
551
+
552
+ // remove old avatar
553
+ if accountDetails.Avatar != nil {
554
+ s.storage.Delete(ctx, *accountDetails.Avatar)
555
+ }
556
+
557
+ url := s.storage.GetURL(path)
558
+ accountDetails.Avatar = &url
559
+
560
+ _, err = s.cvRepository.SaveAccountDetails(ctx, accountDetails)
561
+ if err != nil {
562
+ return nil, response.HandleGormError(err, "Internal Server Error")
563
+ }
564
+
565
+ return &models.UploadProfileImageResponse{
566
+ URL: url,
567
+ }, nil
568
  }
space/space/space/space/space/space/space/services/email_verification_service.go CHANGED
@@ -1,101 +1,101 @@
1
  package services
2
 
3
  import (
4
- "api.qobiltu.id/utils"
5
- "api.qobiltu.id/worker"
6
- "context"
7
- "github.com/hibiken/asynq"
8
- "strconv"
9
- "time"
10
 
11
- "api.qobiltu.id/config"
12
- "api.qobiltu.id/models"
13
- "api.qobiltu.id/repositories"
14
- uuid "github.com/satori/go.uuid"
15
  )
16
 
17
  type EmailVerificationService struct {
18
- Service[models.EmailVerification, models.EmailVerification]
19
  }
20
 
21
  func (s *EmailVerificationService) Create() {
22
- accountRepo := repositories.GetAccountById(s.Constructor.AccountID)
23
- if accountRepo.NoRecord {
24
- s.Error = accountRepo.RowsError
25
- s.Exception.DataNotFound = true
26
- s.Exception.Message = "There is no account data with given credentials!"
27
- return
28
- }
29
 
30
- token, err := utils.GenerateToken()
31
- if err != nil {
32
- s.Error = err
33
- s.Exception.InternalServerError = true
34
- s.Exception.Message = "failed to generate token for email verification"
35
- return
36
- }
37
 
38
- remainingTime := time.Duration(config.EMAIL_VERIFICATION_DURATION) * time.Hour
39
- dueTime := CalculateDueTime(remainingTime)
40
- s.Constructor.UUID = uuid.NewV4()
41
- repo := repositories.CreateEmailVerification(s.Constructor.UUID, s.Constructor.AccountID, dueTime, uint(token))
42
- s.Error = repo.RowsError
43
- s.Result = repo.Result
44
- if s.Error != nil {
45
- return
46
- }
47
 
48
- err = worker.AsyncTaskDistributor.DistributeTaskSendVerifyEmail(
49
- context.Background(),
50
- &worker.PayloadSendVerifyEmail{
51
- EmailAddress: accountRepo.Result.Email,
52
- VerificationCode: strconv.Itoa(int(token)),
53
- ExpirationInMinutes: int(remainingTime.Minutes()),
54
- Subject: worker.TaskSendVerifyEmailSubject,
55
- },
56
- []asynq.Option{
57
- asynq.MaxRetry(worker.TaskSendVerifyEmailMaxRetry),
58
- asynq.Queue(worker.Critical),
59
- }...)
60
- if err != nil {
61
- s.Error = err
62
- s.Exception.InternalServerError = true
63
- s.Exception.Message = "failed to send email verification"
64
- return
65
- }
66
  }
67
 
68
  func (s *EmailVerificationService) Validate() {
69
- repo := repositories.GetEmailVerification(s.Constructor.AccountID, s.Constructor.Token)
70
- s.Error = repo.RowsError
71
 
72
- if repo.NoRecord {
73
- s.Exception.DataNotFound = true
74
- s.Exception.Message = "Invalid token!"
75
- return
76
- }
77
 
78
- if repo.Result.ExpiredAt.Before(time.Now()) {
79
- s.Exception.Unauthorized = true
80
- s.Exception.Message = "Token has expired!"
81
- repositories.UpdateExpiredEmailVerification(s.Constructor.UUID)
82
- s.Delete()
83
- return
84
- }
85
- account := repositories.GetAccountById(repo.Result.AccountID)
86
- account.Result.IsEmailVerified = true
87
 
88
- repositories.UpdateAccount(account.Result)
89
- s.Result = repo.Result
90
  }
91
 
92
  func (s *EmailVerificationService) Delete() {
93
- repo := repositories.DeleteEmailVerification(s.Constructor.Token)
94
- s.Error = repo.RowsError
95
- if repo.NoRecord {
96
- s.Exception.DataNotFound = true
97
- s.Exception.Message = "Invalid token!"
98
- return
99
- }
100
- s.Result = repo.Result
101
  }
 
1
  package services
2
 
3
  import (
4
+ "api.qobiltu.id/pkg/worker"
5
+ "api.qobiltu.id/utils"
6
+ "context"
7
+ "github.com/hibiken/asynq"
8
+ "strconv"
9
+ "time"
10
 
11
+ "api.qobiltu.id/config"
12
+ "api.qobiltu.id/models"
13
+ "api.qobiltu.id/repositories"
14
+ uuid "github.com/satori/go.uuid"
15
  )
16
 
17
  type EmailVerificationService struct {
18
+ Service[models.EmailVerification, models.EmailVerification]
19
  }
20
 
21
  func (s *EmailVerificationService) Create() {
22
+ accountRepo := repositories.GetAccountById(s.Constructor.AccountID)
23
+ if accountRepo.NoRecord {
24
+ s.Error = accountRepo.RowsError
25
+ s.Exception.DataNotFound = true
26
+ s.Exception.Message = "There is no account data with given credentials!"
27
+ return
28
+ }
29
 
30
+ token, err := utils.GenerateToken()
31
+ if err != nil {
32
+ s.Error = err
33
+ s.Exception.InternalServerError = true
34
+ s.Exception.Message = "failed to generate token for email verification"
35
+ return
36
+ }
37
 
38
+ remainingTime := time.Duration(config.EMAIL_VERIFICATION_DURATION) * time.Hour
39
+ dueTime := CalculateDueTime(remainingTime)
40
+ s.Constructor.UUID = uuid.NewV4()
41
+ repo := repositories.CreateEmailVerification(s.Constructor.UUID, s.Constructor.AccountID, dueTime, uint(token))
42
+ s.Error = repo.RowsError
43
+ s.Result = repo.Result
44
+ if s.Error != nil {
45
+ return
46
+ }
47
 
48
+ err = worker.AsyncTaskDistributor.DistributeTaskSendVerifyEmail(
49
+ context.Background(),
50
+ &worker.PayloadSendVerifyEmail{
51
+ EmailAddress: accountRepo.Result.Email,
52
+ VerificationCode: strconv.Itoa(int(token)),
53
+ ExpirationInMinutes: int(remainingTime.Minutes()),
54
+ Subject: worker.TaskSendVerifyEmailSubject,
55
+ },
56
+ []asynq.Option{
57
+ asynq.MaxRetry(worker.TaskSendVerifyEmailMaxRetry),
58
+ asynq.Queue(worker.Critical),
59
+ }...)
60
+ if err != nil {
61
+ s.Error = err
62
+ s.Exception.InternalServerError = true
63
+ s.Exception.Message = "failed to send email verification"
64
+ return
65
+ }
66
  }
67
 
68
  func (s *EmailVerificationService) Validate() {
69
+ repo := repositories.GetEmailVerification(s.Constructor.AccountID, s.Constructor.Token)
70
+ s.Error = repo.RowsError
71
 
72
+ if repo.NoRecord {
73
+ s.Exception.DataNotFound = true
74
+ s.Exception.Message = "Invalid token!"
75
+ return
76
+ }
77
 
78
+ if repo.Result.ExpiredAt.Before(time.Now()) {
79
+ s.Exception.Unauthorized = true
80
+ s.Exception.Message = "Token has expired!"
81
+ repositories.UpdateExpiredEmailVerification(s.Constructor.UUID)
82
+ s.Delete()
83
+ return
84
+ }
85
+ account := repositories.GetAccountById(repo.Result.AccountID)
86
+ account.Result.IsEmailVerified = true
87
 
88
+ repositories.UpdateAccount(account.Result)
89
+ s.Result = repo.Result
90
  }
91
 
92
  func (s *EmailVerificationService) Delete() {
93
+ repo := repositories.DeleteEmailVerification(s.Constructor.Token)
94
+ s.Error = repo.RowsError
95
+ if repo.NoRecord {
96
+ s.Exception.DataNotFound = true
97
+ s.Exception.Message = "Invalid token!"
98
+ return
99
+ }
100
+ s.Result = repo.Result
101
  }
space/space/space/space/space/space/space/services/forgot_password_service.go CHANGED
@@ -1,8 +1,8 @@
1
  package services
2
 
3
  import (
 
4
  "api.qobiltu.id/utils"
5
- "api.qobiltu.id/worker"
6
  "context"
7
  "github.com/hibiken/asynq"
8
  "strconv"
 
1
  package services
2
 
3
  import (
4
+ "api.qobiltu.id/pkg/worker"
5
  "api.qobiltu.id/utils"
 
6
  "context"
7
  "github.com/hibiken/asynq"
8
  "strconv"
space/space/space/space/space/space/space/space/.gitignore CHANGED
@@ -7,3 +7,4 @@ README.md
7
  logs/
8
  .idea
9
  my-notes
 
 
7
  logs/
8
  .idea
9
  my-notes
10
+ uploads
space/space/space/space/space/space/space/space/pkg/validation/custom_rules.go ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package validation
2
+
3
+ import (
4
+ "strings"
5
+ "sync"
6
+
7
+ v10 "github.com/go-playground/validator/v10"
8
+ "gorm.io/gorm"
9
+ )
10
+
11
+ type ValidOptionSource interface {
12
+ GetValidOptions(key string) ([]string, error)
13
+ GetValidKeys() []string
14
+ }
15
+
16
+ // --------------------
17
+ // InMemoryOptionSource
18
+ // --------------------
19
+
20
+ type InMemoryOptionSource struct{}
21
+
22
+ var inMemoryOptions = map[string][]string{
23
+ "last_education": {"SD", "SMP", "SMA", "D1", "D2", "D3", "D4", "D5", "S1", "S2", "S3"},
24
+ "marital_status": {"Belum Menikah", "Duda", "Janda"},
25
+ "gender": {"Laki-laki", "Perempuan"},
26
+ "monthly_expenses": {"< 2 Juta", "2-5 Juta", "5-20 Juta", "> 10 Juta"},
27
+ "monthly_income": {"< 3 Juta", "3-5 Juta", "5-10 Juta", "> 10 Juta"},
28
+ "religion": {"Islam", "Non-Islam"},
29
+ "family_role": {"Ayah", "Ibu", "Kakak", "Adik", "Anak"},
30
+ "life_status": {"Hidup", "Wafat"},
31
+ "body_shape": {"Ideal", "Kurus", "Berisi", "Gemuk"},
32
+ "skin_color": {"Putih", "Kuning Langsat", "Sawo Matang"},
33
+ "hair_type": {"Lurus", "Bergelombang", "Keriting"},
34
+ "frequently": {"Selalu", "Sering", "Kadang", "Jarang"},
35
+ "quran_reading_ability": {"Lancar", "Menengah", "Perlu Bimbingan"},
36
+ }
37
+
38
+ func (s *InMemoryOptionSource) GetValidOptions(key string) ([]string, error) {
39
+ return inMemoryOptions[key], nil
40
+ }
41
+
42
+ func (s *InMemoryOptionSource) GetValidKeys() []string {
43
+ keys := make([]string, 0, len(inMemoryOptions))
44
+ for k := range inMemoryOptions {
45
+ keys = append(keys, k)
46
+ }
47
+ return keys
48
+ }
49
+
50
+ // --------------------
51
+ // DBOptionSource
52
+ // --------------------
53
+
54
+ type DBOptionSource struct {
55
+ options map[string][]string
56
+ mu sync.RWMutex
57
+ }
58
+
59
+ type (
60
+ OptionCategory struct {
61
+ ID int64 `gorm:"primaryKey" json:"id"`
62
+ OptionName string `json:"option_name"`
63
+ OptionSlug string `json:"option_slug" gorm:"uniqueIndex"`
64
+ }
65
+
66
+ OptionValues struct {
67
+ ID int64 `gorm:"primaryKey" json:"id"`
68
+ OptionCategoryID int64 `json:"option_category_id"`
69
+ OptionValue string `json:"option_value"`
70
+ }
71
+ )
72
+
73
+ func NewDBOptionSource(db *gorm.DB) (*DBOptionSource, error) {
74
+ var categories []OptionCategory
75
+ if err := db.Find(&categories).Error; err != nil {
76
+ return nil, err
77
+ }
78
+
79
+ options := make(map[string][]string)
80
+ for _, cat := range categories {
81
+ var values []OptionValues
82
+ if err := db.Where("option_category_id = ?", cat.ID).Find(&values).Error; err != nil {
83
+ return nil, err
84
+ }
85
+ for _, val := range values {
86
+ options[cat.OptionSlug] = append(options[cat.OptionSlug], val.OptionValue)
87
+ }
88
+ }
89
+
90
+ return &DBOptionSource{options: options}, nil
91
+ }
92
+
93
+ func (s *DBOptionSource) GetValidOptions(key string) ([]string, error) {
94
+ s.mu.RLock()
95
+ defer s.mu.RUnlock()
96
+ return s.options[key], nil
97
+ }
98
+
99
+ func (s *DBOptionSource) GetValidKeys() []string {
100
+ s.mu.RLock()
101
+ defer s.mu.RUnlock()
102
+ keys := make([]string, 0, len(s.options))
103
+ for k := range s.options {
104
+ keys = append(keys, k)
105
+ }
106
+ return keys
107
+ }
108
+
109
+ // --------------------
110
+ // Validator
111
+ // --------------------
112
+
113
+ type Validator struct {
114
+ source ValidOptionSource
115
+ }
116
+
117
+ func NewValidatorRules(source ValidOptionSource) *Validator {
118
+ return &Validator{source: source}
119
+ }
120
+
121
+ func (v *Validator) GenericOptionRule(key string) func(fl v10.FieldLevel) bool {
122
+ return func(fl v10.FieldLevel) bool {
123
+ value := fl.Field().String()
124
+ if value == "" {
125
+ return true
126
+ }
127
+ validOptions, err := v.source.GetValidOptions(key)
128
+ if err != nil {
129
+ return false
130
+ }
131
+ for _, opt := range validOptions {
132
+ if opt == value {
133
+ return true
134
+ }
135
+ }
136
+ return false
137
+ }
138
+ }
139
+
140
+ func (v *Validator) RegisterAllCustomRules(validate *v10.Validate) error {
141
+ for _, key := range v.source.GetValidKeys() {
142
+ err := validate.RegisterValidation(key, v.GenericOptionRule(key))
143
+ if err != nil {
144
+ return err
145
+ }
146
+ }
147
+
148
+ err := validate.RegisterValidation("password", v.PasswordRule)
149
+ if err != nil {
150
+ return err
151
+ }
152
+
153
+ return nil
154
+ }
155
+
156
+ func (v *Validator) PasswordRule(fl v10.FieldLevel) bool {
157
+ password := fl.Field().String()
158
+ return len(password) >= 8 &&
159
+ strings.ContainsAny(password, "abcdefghijklmnopqrstuvwxyz") &&
160
+ strings.ContainsAny(password, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") &&
161
+ strings.ContainsAny(password, "0123456789")
162
+ }
space/space/space/space/space/space/space/space/pkg/validation/validation.go ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package validation
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "strings"
7
+
8
+ "github.com/go-playground/locales/en"
9
+ "github.com/go-playground/locales/id"
10
+ ut "github.com/go-playground/universal-translator"
11
+ v10 "github.com/go-playground/validator/v10"
12
+ entranslations "github.com/go-playground/validator/v10/translations/en"
13
+ idtranslations "github.com/go-playground/validator/v10/translations/id"
14
+ )
15
+
16
+ // Constants for supported locales
17
+ const (
18
+ LocaleID = "id"
19
+ LocaleEN = "en"
20
+ )
21
+
22
+ // ErrorMessage represents a validation error message
23
+ type ErrorMessage struct {
24
+ Field string `json:"field"`
25
+ Message string `json:"-"`
26
+ }
27
+
28
+ type validator struct {
29
+ validate *v10.Validate
30
+ translator ut.Translator
31
+ }
32
+
33
+ // validatorInstance adalah instance global dari validator.
34
+ var validatorInstance *validator
35
+
36
+ // New creates a new validation instance with the specified locale
37
+ // dan menginisialisasi instance global validatorInstance.
38
+ func New(locale string) error {
39
+ v := &validator{}
40
+ parsedLocale := parseLocale(locale)
41
+
42
+ uni := ut.New(en.New(), id.New(), en.New())
43
+ translator, found := uni.GetTranslator(parsedLocale)
44
+ if !found {
45
+ return fmt.Errorf("translator not found for locale: %s", parsedLocale)
46
+ }
47
+
48
+ validate := v10.New()
49
+
50
+ if err := setupValidations(validate); err != nil {
51
+ return fmt.Errorf("failed to setup validations: %w", err)
52
+ }
53
+
54
+ if err := setupTranslations(validate, translator, parsedLocale); err != nil {
55
+ return fmt.Errorf("failed to setup translations for locale %s: %w", parsedLocale, err)
56
+ }
57
+
58
+ v.validate = validate
59
+ v.translator = translator
60
+
61
+ validatorInstance = v // Inisialisasi instance global
62
+ return nil
63
+ }
64
+
65
+ func parseLocale(locale string) string {
66
+ switch strings.ToLower(locale) {
67
+ case "id":
68
+ return LocaleID
69
+ case "en":
70
+ return LocaleEN
71
+ default:
72
+ return LocaleID // Default to Indonesian
73
+ }
74
+ }
75
+
76
+ // setupValidations configures custom validation rules.
77
+ func setupValidations(validate *v10.Validate) error {
78
+ rules := NewValidatorRules(&InMemoryOptionSource{})
79
+ if err := rules.RegisterAllCustomRules(validate); err != nil {
80
+ return err
81
+ }
82
+ return nil
83
+ }
84
+
85
+ // setupTranslations configures translations for validation messages.
86
+ func setupTranslations(validate *v10.Validate, translator ut.Translator, locale string) error {
87
+ // Register default translations based on locale
88
+ if err := registerDefaultTranslations(validate, translator, locale); err != nil {
89
+ return fmt.Errorf("failed to register default translations for locale %s: %w", locale, err)
90
+ }
91
+
92
+ // Register custom password validation translation
93
+ err := validate.RegisterTranslation("password", translator,
94
+ func(ut ut.Translator) error {
95
+ return ut.Add("password", "harus mengandung minimal 8 karakter, huruf besar, huruf kecil, dan angka.", true)
96
+ },
97
+ func(ut ut.Translator, fe v10.FieldError) string {
98
+ translated, err := ut.T(fe.Tag(), fe.Field())
99
+ if err != nil {
100
+ return fe.Field() + " is invalid"
101
+ }
102
+ return translated
103
+ },
104
+ )
105
+ if err != nil {
106
+ return fmt.Errorf("failed to register password translation: %w", err)
107
+ }
108
+
109
+ return nil
110
+ }
111
+
112
+ // registerDefaultTranslations sets up default translations for the specified locale.
113
+ func registerDefaultTranslations(validate *v10.Validate, translator ut.Translator, locale string) error {
114
+ switch locale {
115
+ case LocaleID:
116
+ return idtranslations.RegisterDefaultTranslations(validate, translator)
117
+ case LocaleEN:
118
+ return entranslations.RegisterDefaultTranslations(validate, translator)
119
+ default:
120
+ // Fallback to English if the locale is not supported
121
+ return entranslations.RegisterDefaultTranslations(validate, translator)
122
+ }
123
+ }
124
+
125
+ // Validate validates a struct using the global validator instance
126
+ // and returns a slice of ErrorMessage.
127
+ func Validate(s any) []ErrorMessage {
128
+ if validatorInstance == nil {
129
+ return []ErrorMessage{{Field: "", Message: "Validator belum diinisialisasi. Panggil validation.New() terlebih dahulu."}}
130
+ }
131
+ err := validatorInstance.validate.Struct(s)
132
+ if err != nil {
133
+ return TranslateError(err)
134
+ }
135
+ return nil
136
+ }
137
+
138
+ // TranslateError takes a validation error and translates it using the global translator.
139
+ func TranslateError(err error) []ErrorMessage {
140
+ if validatorInstance == nil {
141
+ return nil
142
+ }
143
+
144
+ var validationErrors v10.ValidationErrors
145
+ if !errors.As(err, &validationErrors) {
146
+ return nil
147
+ }
148
+
149
+ errorMessages := make([]ErrorMessage, 0, len(validationErrors))
150
+ for _, e := range validationErrors {
151
+ fieldLabel := e.Field()
152
+ msg, err := validatorInstance.translator.T(e.Tag(), fieldLabel)
153
+ if err != nil {
154
+ msg = fieldLabel + " is Invalid"
155
+ }
156
+
157
+ errorMessages = append(errorMessages, ErrorMessage{
158
+ Field: e.Tag(),
159
+ Message: msg,
160
+ })
161
+ }
162
+
163
+ return errorMessages
164
+ }
space/space/space/space/space/space/space/space/pkg/worker/distributor.go ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package worker
2
+
3
+ import (
4
+ "context"
5
+ "github.com/hibiken/asynq"
6
+ )
7
+
8
+ type TaskDistributor interface {
9
+ DistributeTaskSendVerifyEmail(
10
+ ctx context.Context,
11
+ payload *PayloadSendVerifyEmail,
12
+ opts ...asynq.Option,
13
+ ) error
14
+
15
+ DistributeTaskSendForgotPasswordEmail(
16
+ ctx context.Context,
17
+ payload *PayloadSendForgotPasswordEmail,
18
+ opts ...asynq.Option,
19
+ ) error
20
+ }
21
+
22
+ // AsyncTaskDistributor is a global variable to hold the task distributor
23
+ var AsyncTaskDistributor TaskDistributor
space/space/space/space/space/space/space/space/pkg/worker/logger.go ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package worker
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "log/slog"
7
+ )
8
+
9
+ type Logger struct{}
10
+
11
+ func NewLogger() *Logger {
12
+ return &Logger{}
13
+ }
14
+
15
+ func (logger *Logger) Printf(ctx context.Context, format string, v ...interface{}) {
16
+ slog.Info(fmt.Sprintf(format, v...))
17
+ }
18
+
19
+ func (logger *Logger) Debug(args ...interface{}) {
20
+ slog.Debug(fmt.Sprint(args...))
21
+ }
22
+
23
+ func (logger *Logger) Info(args ...interface{}) {
24
+ slog.Info(fmt.Sprint(args...))
25
+ }
26
+
27
+ func (logger *Logger) Warn(args ...interface{}) {
28
+ slog.Warn(fmt.Sprint(args...))
29
+ }
30
+
31
+ func (logger *Logger) Error(args ...interface{}) {
32
+ slog.Error(fmt.Sprint(args...))
33
+ }
34
+
35
+ func (logger *Logger) Fatal(args ...interface{}) {
36
+ slog.Error(fmt.Sprint(args...))
37
+ }
space/space/space/space/space/space/space/space/pkg/worker/processor.go ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package worker
2
+
3
+ import (
4
+ "api.qobiltu.id/mail"
5
+ "context"
6
+ "github.com/hibiken/asynq"
7
+ "github.com/redis/go-redis/v9"
8
+ "log/slog"
9
+ )
10
+
11
+ const (
12
+ Low = "low"
13
+ Default = "default"
14
+ Critical = "critical"
15
+ )
16
+
17
+ type TaskProcessor interface {
18
+ Start() error
19
+ Shutdown()
20
+ ProcessTaskSendVerifyEmail(ctx context.Context, task *asynq.Task) error
21
+ ProcessTaskSendForgotPasswordEmail(ctx context.Context, task *asynq.Task) error
22
+ }
23
+
24
+ type RedisTaskProcessor struct {
25
+ server *asynq.Server
26
+ emailSender mail.Sender
27
+ }
28
+
29
+ func NewRedisTaskProcessor(redisOpt asynq.RedisClientOpt, emailSender mail.Sender) TaskProcessor {
30
+ logger := NewLogger()
31
+ redis.SetLogger(logger)
32
+
33
+ server := asynq.NewServer(
34
+ redisOpt,
35
+ asynq.Config{
36
+ // priority value. Keys are the names of the queues and values are associated priority value.
37
+ Queues: map[string]int{
38
+ Critical: 6,
39
+ Default: 3,
40
+ Low: 1,
41
+ },
42
+ ErrorHandler: asynq.ErrorHandlerFunc(func(ctx context.Context, task *asynq.Task, err error) {
43
+ slog.Error("process task failed", "error", err, "type", task.Type(), "payload", string(task.Payload()))
44
+ }),
45
+ // maximum number of concurrent processing of tasks.
46
+ Concurrency: 50,
47
+ Logger: logger,
48
+ },
49
+ )
50
+
51
+ return &RedisTaskProcessor{
52
+ server: server,
53
+ emailSender: emailSender,
54
+ }
55
+ }
56
+
57
+ func (p *RedisTaskProcessor) Start() error {
58
+ mux := asynq.NewServeMux()
59
+ mux.HandleFunc(TaskSendVerifyEmail, p.ProcessTaskSendVerifyEmail)
60
+ mux.HandleFunc(TaskSendForgotPasswordEmail, p.ProcessTaskSendForgotPasswordEmail)
61
+ return p.server.Start(mux)
62
+ }
63
+
64
+ func (p *RedisTaskProcessor) Shutdown() {
65
+ p.server.Shutdown()
66
+ }
space/space/space/space/space/space/space/space/pkg/worker/redis_distributor.go ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package worker
2
+
3
+ import (
4
+ "github.com/hibiken/asynq"
5
+ )
6
+
7
+ type RedisTaskDistributor struct {
8
+ client *asynq.Client
9
+ }
10
+
11
+ func NewRedisTaskDistributor(redisOpt asynq.RedisClientOpt) TaskDistributor {
12
+ client := asynq.NewClient(redisOpt)
13
+ return &RedisTaskDistributor{
14
+ client: client,
15
+ }
16
+ }
space/space/space/space/space/space/space/space/pkg/worker/task_send_forgot_password_email.go ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package worker
2
+
3
+ import (
4
+ "api.qobiltu.id/assets"
5
+ "bytes"
6
+ "context"
7
+ "encoding/json"
8
+ "fmt"
9
+ "github.com/hibiken/asynq"
10
+ "html/template"
11
+ "log/slog"
12
+ )
13
+
14
+ const (
15
+ TaskSendForgotPasswordEmailMaxRetry = 5
16
+ TaskSendForgotPasswordEmail = "task:send_forgot_password_email"
17
+ TaskSendForgotPasswordEmailSubject = "Permintaan Reset Password"
18
+ )
19
+
20
+ type PayloadSendForgotPasswordEmail struct {
21
+ EmailAddress string `json:"email_address"`
22
+ ResetToken string `json:"reset_token"`
23
+ ExpirationInMinutes int `json:"expiration_in_minutes"`
24
+ Subject string `json:"subject"`
25
+ AppName string `json:"app_name"`
26
+ }
27
+
28
+ func (d *RedisTaskDistributor) DistributeTaskSendForgotPasswordEmail(
29
+ ctx context.Context,
30
+ payload *PayloadSendForgotPasswordEmail,
31
+ opts ...asynq.Option,
32
+ ) error {
33
+ jsonPayload, err := json.Marshal(payload)
34
+ if err != nil {
35
+ return fmt.Errorf("failed to marshal task payload: %w", err)
36
+ }
37
+
38
+ task := asynq.NewTask(TaskSendForgotPasswordEmail, jsonPayload, opts...)
39
+
40
+ _, err = d.client.EnqueueContext(ctx, task)
41
+ if err != nil {
42
+ return fmt.Errorf("failed to enqueue task: %w", err)
43
+ }
44
+
45
+ return nil
46
+ }
47
+
48
+ func (p *RedisTaskProcessor) ProcessTaskSendForgotPasswordEmail(ctx context.Context, task *asynq.Task) error {
49
+ var payload PayloadSendForgotPasswordEmail
50
+ if err := json.Unmarshal(task.Payload(), &payload); err != nil {
51
+ return fmt.Errorf("failed to unmarshal payload: %w", asynq.SkipRetry)
52
+ }
53
+
54
+ var tmpl *template.Template
55
+ tmpl, err := template.ParseFS(assets.EmbeddedFiles, assets.EmailForgotPasswordTemplatePath)
56
+ if err != nil {
57
+ return fmt.Errorf("failed to parse forgot password email template: %w", err)
58
+ }
59
+ var body bytes.Buffer
60
+ if err := tmpl.ExecuteTemplate(&body, "htmlBody", payload); err != nil {
61
+ return fmt.Errorf("failed to execute forgot password email template: %w", err)
62
+ }
63
+ htmlContent := body.String()
64
+
65
+ slog.Info("Sending forgot password email", slog.String("email", payload.EmailAddress))
66
+
67
+ err = p.emailSender.Send(payload.EmailAddress, payload.Subject, htmlContent, payload)
68
+ if err != nil {
69
+ return fmt.Errorf("failed to send forgot password email: %w", err)
70
+ }
71
+
72
+ slog.Info("Forgot password email sent successfully", slog.String("email", payload.EmailAddress))
73
+
74
+ return nil
75
+ }
space/space/space/space/space/space/space/space/pkg/worker/task_send_verify_email.go ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package worker
2
+
3
+ import (
4
+ "api.qobiltu.id/assets"
5
+ "bytes"
6
+ "context"
7
+ "encoding/json"
8
+ "fmt"
9
+ "github.com/hibiken/asynq"
10
+ "html/template"
11
+ "log/slog"
12
+ )
13
+
14
+ const (
15
+ TaskSendVerifyEmailMaxRetry = 3
16
+ TaskSendVerifyEmail = "task:send_verify_email"
17
+ TaskSendVerifyEmailSubject = "Verifikasi Email"
18
+ )
19
+
20
+ type PayloadSendVerifyEmail struct {
21
+ EmailAddress string `json:"email_address"`
22
+ VerificationCode string `json:"verification_code"`
23
+ ExpirationInMinutes int `json:"expiration_in_minutes"`
24
+ Subject string `json:"subject"`
25
+ }
26
+
27
+ func (d *RedisTaskDistributor) DistributeTaskSendVerifyEmail(
28
+ ctx context.Context,
29
+ payload *PayloadSendVerifyEmail,
30
+ opts ...asynq.Option,
31
+ ) error {
32
+ jsonPayload, err := json.Marshal(payload)
33
+ if err != nil {
34
+ return fmt.Errorf("failed to marshal task payload: %w", err)
35
+ }
36
+
37
+ task := asynq.NewTask(TaskSendVerifyEmail, jsonPayload, opts...)
38
+
39
+ _, err = d.client.EnqueueContext(ctx, task)
40
+ if err != nil {
41
+ return fmt.Errorf("failed to enqueue task: %w", err)
42
+ }
43
+
44
+ return nil
45
+ }
46
+
47
+ func (p *RedisTaskProcessor) ProcessTaskSendVerifyEmail(ctx context.Context, task *asynq.Task) error {
48
+ var payload PayloadSendVerifyEmail
49
+ if err := json.Unmarshal(task.Payload(), &payload); err != nil {
50
+ return fmt.Errorf("failed to unmarshal payload: %w", asynq.SkipRetry)
51
+ }
52
+
53
+ var tmpl *template.Template
54
+ tmpl, err := template.ParseFS(assets.EmbeddedFiles, assets.EmailConfirmationTemplatePath)
55
+ if err != nil {
56
+ return fmt.Errorf("failed to parse email template: %w", err)
57
+ }
58
+
59
+ var body bytes.Buffer
60
+ if err := tmpl.ExecuteTemplate(&body, "htmlBody", payload); err != nil {
61
+ return fmt.Errorf("failed to execute email template: %w", err)
62
+ }
63
+ htmlContent := body.String()
64
+
65
+ slog.Info("Sending verification email", slog.String("email", payload.EmailAddress))
66
+
67
+ err = p.emailSender.Send(payload.EmailAddress, payload.Subject, htmlContent, payload)
68
+ if err != nil {
69
+ return fmt.Errorf("failed to send verify email: %w", err)
70
+ }
71
+
72
+ slog.Info("Verification email sent successfully", slog.String("email", payload.EmailAddress))
73
+
74
+ return nil
75
+ }
space/space/space/space/space/space/space/space/space/main.go CHANGED
@@ -9,6 +9,7 @@ import (
9
  "api.qobiltu.id/router"
10
  "api.qobiltu.id/services"
11
  "api.qobiltu.id/utils"
 
12
  "api.qobiltu.id/worker"
13
  "github.com/hibiken/asynq"
14
  "log/slog"
@@ -18,6 +19,10 @@ import (
18
 
19
  func main() {
20
 
 
 
 
 
21
  // setup email sender
22
  emailConfig := mail.Config{
23
  Host: config.SMTP_HOST,
 
9
  "api.qobiltu.id/router"
10
  "api.qobiltu.id/services"
11
  "api.qobiltu.id/utils"
12
+ "api.qobiltu.id/validation"
13
  "api.qobiltu.id/worker"
14
  "github.com/hibiken/asynq"
15
  "log/slog"
 
19
 
20
  func main() {
21
 
22
+ // setup validation
23
+ err := validation.New(validation.LocaleID)
24
+ utils.FatalIfErr("failed to setup validator", err)
25
+
26
  // setup email sender
27
  emailConfig := mail.Config{
28
  Host: config.SMTP_HOST,
space/space/space/space/space/space/space/space/space/models/exception_model.go CHANGED
@@ -1,23 +1,26 @@
1
  package models
2
 
 
 
3
  type Exception struct {
4
- Unauthorized bool `json:"unauthorized,omitempty"`
5
- BadRequest bool `json:"bad_request,omitempty"`
6
- DataNotFound bool `json:"data_not_found,omitempty"`
7
- InternalServerError bool `json:"internal_server_error,omitempty"`
8
- DataDuplicate bool `json:"data_duplicate,omitempty"`
9
- QueryError bool `json:"query_error,omitempty"`
10
- InvalidPasswordLength bool `json:"invalid_password_length,omitempty"`
11
- IsPassTheLimit bool `json:"is_pass_the_limit,omitempty"`
12
- IsTimeOut bool `json:"is_time_out,omitempty"`
13
- AttemptNotFound bool `json:"attempt_not_found,omitempty"`
14
- Forbidden bool `json:"forbidden,omitempty"`
15
- ValidationError bool `json:"validation_error,omitempty"`
16
 
17
- Message string `json:"message,omitempty"`
18
- Err error `json:"-"`
 
19
  }
20
 
21
  func (a Exception) Error() string {
22
- return a.Err.Error()
23
  }
 
1
  package models
2
 
3
+ import "api.qobiltu.id/validation"
4
+
5
  type Exception struct {
6
+ Unauthorized bool `json:"unauthorized,omitempty"`
7
+ BadRequest bool `json:"bad_request,omitempty"`
8
+ DataNotFound bool `json:"data_not_found,omitempty"`
9
+ InternalServerError bool `json:"internal_server_error,omitempty"`
10
+ DataDuplicate bool `json:"data_duplicate,omitempty"`
11
+ QueryError bool `json:"query_error,omitempty"`
12
+ InvalidPasswordLength bool `json:"invalid_password_length,omitempty"`
13
+ IsPassTheLimit bool `json:"is_pass_the_limit,omitempty"`
14
+ IsTimeOut bool `json:"is_time_out,omitempty"`
15
+ AttemptNotFound bool `json:"attempt_not_found,omitempty"`
16
+ Forbidden bool `json:"forbidden,omitempty"`
17
+ ValidationError bool `json:"validation_error,omitempty"`
18
 
19
+ Message string `json:"message,omitempty"`
20
+ Err error `json:"-"`
21
+ ValidationErrorFields []validation.ErrorMessage `json:"validation_error_fields,omitempty"`
22
  }
23
 
24
  func (a Exception) Error() string {
25
+ return a.Err.Error()
26
  }