lifedebugger commited on
Commit
36c99b0
·
1 Parent(s): a6ebab6

Deploy files from GitHub repository

Browse files
.gitignore CHANGED
@@ -1 +1,2 @@
1
- /.env
 
 
1
+ /.env
2
+ go-boilerplate.exe
config/supabase.go DELETED
@@ -1,19 +0,0 @@
1
- package config
2
-
3
- type Supabase interface{
4
- NewSupabaseConfig(url string, key string, bucket string)
5
- }
6
-
7
- type SupabaseConfig struct {
8
- URL string
9
- ServiceKey string
10
- BucketName string
11
- }
12
-
13
- func NewSupabaseConfig(url string, key string, bucket string) SupabaseConfig {
14
- return SupabaseConfig{
15
- URL: url,
16
- ServiceKey: key,
17
- BucketName: bucket,
18
- }
19
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
config/supabase_config.go ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ type SupabaseConfig interface {
4
+ GetURL() string
5
+ GetServiceKey() string
6
+ GetBucketName() string
7
+ }
8
+
9
+ type supabaseConfig struct {
10
+ url string
11
+ serviceKey string
12
+ bucketName string
13
+ }
14
+
15
+ func NewSupabaseConfig(url string, key string, bucket string) SupabaseConfig {
16
+ return &supabaseConfig{
17
+ url: url,
18
+ serviceKey: key,
19
+ bucketName: bucket,
20
+ }
21
+ }
22
+
23
+ func (c *supabaseConfig) GetURL() string { return c.url }
24
+ func (c *supabaseConfig) GetServiceKey() string { return c.serviceKey }
25
+ func (c *supabaseConfig) GetBucketName() string { return c.bucketName }
config/upload_config.go ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package config
2
+
3
+ import models "abdanhafidz.com/go-boilerplate/models/entity"
4
+
5
+
6
+ type UploadRule struct {
7
+ MaxBytes int64
8
+ AllowedExts map[string]bool
9
+ PathPrefix string
10
+ MaxCount int
11
+ }
12
+
13
+ type UploadConfig interface {
14
+ Get(contextType string) (UploadRule, error)
15
+ }
16
+
17
+ type uploadConfig struct{}
18
+
19
+ func NewUploadConfig() UploadConfig { return &uploadConfig{} }
20
+
21
+ func (c *uploadConfig) Get(contextType string) (UploadRule, error) {
22
+ codeExts := map[string]bool{".cpp": true, ".c": true, ".py": true, ".java": true, ".go": true, ".js": true, ".txt": true}
23
+ imgExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".gif": true}
24
+ docExts := map[string]bool{".pdf": true, ".doc": true, ".docx": true}
25
+
26
+ allExts := make(map[string]bool)
27
+ for k, v := range codeExts { allExts[k] = v }
28
+ for k, v := range imgExts { allExts[k] = v }
29
+ for k, v := range docExts { allExts[k] = v }
30
+
31
+ switch contextType {
32
+ case "image":
33
+ return UploadRule{ MaxBytes: 10 * models.MB, AllowedExts: imgExts, PathPrefix: "images", MaxCount: 5 }, nil
34
+ case "material":
35
+ return UploadRule{ MaxBytes: 10 * models.MB, AllowedExts: docExts, PathPrefix: "materials", MaxCount: 1 }, nil
36
+ case "submission":
37
+ return UploadRule{ MaxBytes: 1 * models.MB, AllowedExts: codeExts, PathPrefix: "submissions", MaxCount: 1 }, nil
38
+ case "general":
39
+ return UploadRule{ MaxBytes: 5 * models.MB, AllowedExts: allExts, PathPrefix: "temp", MaxCount: 5 }, nil
40
+ default:
41
+ return UploadRule{}, ErrInvalidUploadContext
42
+ }
43
+ }
44
+
45
+ var ErrInvalidUploadContext = errInvalidUploadContext{}
46
+
47
+ type errInvalidUploadContext struct{}
48
+
49
+ func (e errInvalidUploadContext) Error() string { return "invalid upload context" }
controllers/academy_controller.go CHANGED
@@ -1,7 +1,6 @@
1
  package controllers
2
 
3
  import (
4
- "fmt"
5
  "net/http"
6
  "strconv"
7
 
@@ -77,7 +76,6 @@ func (c *academyController) ListAcademies(ctx *gin.Context) {
77
  return
78
  }
79
 
80
- fmt.Println("Account ID in ListAcademies:", accountId)
81
  res, err := c.academyService.ListAcademies(ctx.Request.Context(), accountId)
82
  ResponseJSON(ctx, gin.H{}, res, err)
83
  }
 
1
  package controllers
2
 
3
  import (
 
4
  "net/http"
5
  "strconv"
6
 
 
76
  return
77
  }
78
 
 
79
  res, err := c.academyService.ListAcademies(ctx.Request.Context(), accountId)
80
  ResponseJSON(ctx, gin.H{}, res, err)
81
  }
controllers/upload_controller.go CHANGED
@@ -18,12 +18,10 @@ import (
18
  )
19
 
20
  type UploadController struct {
21
- uploadService *services.UploadService
22
  }
23
 
24
- func NewUploadController(s *services.UploadService) *UploadController {
25
- return &UploadController{uploadService: s}
26
- }
27
 
28
  func (c *UploadController) Upload(ctx *gin.Context) {
29
  fmt.Println("👉 Content-Type:", ctx.GetHeader("Content-Type"))
@@ -248,4 +246,4 @@ func (c *UploadController) inferContextFromExt(ext string) string {
248
  return "material"
249
  }
250
  return ""
251
- }
 
18
  )
19
 
20
  type UploadController struct {
21
+ uploadService services.UploadService
22
  }
23
 
24
+ func NewUploadController(s services.UploadService) *UploadController { return &UploadController{ uploadService: s } }
 
 
25
 
26
  func (c *UploadController) Upload(ctx *gin.Context) {
27
  fmt.Println("👉 Content-Type:", ctx.GetHeader("Content-Type"))
 
246
  return "material"
247
  }
248
  return ""
249
+ }
models/entity/contant.go CHANGED
@@ -4,4 +4,6 @@ const (
4
  StatusNotStarted = "NOT_STARTED"
5
  StatusInProgress = "IN_PROGRESS"
6
  StatusCompleted = "COMPLETED"
7
- )
 
 
 
4
  StatusNotStarted = "NOT_STARTED"
5
  StatusInProgress = "IN_PROGRESS"
6
  StatusCompleted = "COMPLETED"
7
+ )
8
+
9
+ const MB = 1024 * 1024
models/entity/entity.go CHANGED
@@ -267,7 +267,7 @@ type Academy struct {
267
  ImageUrl string `json:"image_url,omitempty"`
268
  MaterialsCount int64 `json:"materials_count,omitempty"`
269
  Materials []AcademyMaterial `gorm:"foreignKey:AcademyId;references:Id" json:"materials,omitempty"`
270
- AcademyProgresss AcademyProgress `gorm:"foreignKey:AcademyId;references:Id" json:"academy_progresses,omitempty"`
271
  }
272
 
273
  func (Academy) TableName() string { return "academy" }
@@ -300,9 +300,9 @@ func (AcademyContent) TableName() string { return "academy_contents" }
300
  // Progress
301
 
302
  type AcademyProgress struct {
303
- Id uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
304
- AccountId uuid.UUID `gorm:"type:uuid;index" json:"account_id,omitempty"`
305
- AcademyId uuid.UUID `gorm:"type:uuid;index" json:"academy_id,omitempty"`
306
  Status string `gorm:"type:varchar(50);default:'not attempted'" json:"status,omitempty"`
307
  Progress float64 `gorm:"default:0" json:"progress"`
308
  TotalCompletedMaterials uint `gorm:"default:0" json:"total_completed_materials"`
@@ -312,10 +312,10 @@ type AcademyProgress struct {
312
  func (AcademyProgress) TableName() string { return "academy_progress" }
313
 
314
  type AcademyMaterialProgress struct {
315
- Id uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
316
- AccountId uuid.UUID `gorm:"type:uuid;index" json:"account_id,omitempty"`
317
- AcademyId uuid.UUID `gorm:"type:uuid;index" json:"academy_id,omitempty"`
318
- MaterialId uuid.UUID `gorm:"type:uuid;index" json:"material_id,omitempty"`
319
  Progress float64 `gorm:"default:0" json:"progress,omitempty"`
320
  TotalCompletedContents uint `gorm:"default:0" json:"total_completed_contents,omitempty"`
321
  Status string `gorm:"type:varchar(50);default:'not attempted'" json:"status,omitempty"`
@@ -325,11 +325,11 @@ type AcademyMaterialProgress struct {
325
  func (AcademyMaterialProgress) TableName() string { return "academy_material_progress" }
326
 
327
  type AcademyContentProgress struct {
328
- Id uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
329
- AccountId uuid.UUID `gorm:"type:uuid;index" json:"account_id,omitempty"`
330
- AcademyId uuid.UUID `gorm:"type:uuid;index" json:"academy_id,omitempty"`
331
- MaterialId uuid.UUID `gorm:"type:uuid;index" json:"material_id,omitempty"`
332
- ContentId uuid.UUID `gorm:"type:uuid;index" json:"content_id,omitempty"`
333
  Status string `gorm:"type:varchar(50);default:'not attempted'" json:"status,omitempty"`
334
  CompletedAt *time.Time `json:"completed_at"`
335
  }
 
267
  ImageUrl string `json:"image_url,omitempty"`
268
  MaterialsCount int64 `json:"materials_count,omitempty"`
269
  Materials []AcademyMaterial `gorm:"foreignKey:AcademyId;references:Id" json:"materials,omitempty"`
270
+ AcademyProgress AcademyProgress `gorm:"foreignKey:AcademyId;references:Id" json:"academy_progresses,omitempty"`
271
  }
272
 
273
  func (Academy) TableName() string { return "academy" }
 
300
  // Progress
301
 
302
  type AcademyProgress struct {
303
+ Id uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
304
+ AccountId uuid.UUID `gorm:"type:uuid;uniqueIndex:idx_account_academy" json:"account_id,omitempty"`
305
+ AcademyId uuid.UUID `gorm:"type:uuid;uniqueIndex:idx_account_academy" json:"academy_id,omitempty"`
306
  Status string `gorm:"type:varchar(50);default:'not attempted'" json:"status,omitempty"`
307
  Progress float64 `gorm:"default:0" json:"progress"`
308
  TotalCompletedMaterials uint `gorm:"default:0" json:"total_completed_materials"`
 
312
  func (AcademyProgress) TableName() string { return "academy_progress" }
313
 
314
  type AcademyMaterialProgress struct {
315
+ Id uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
316
+ AccountId uuid.UUID `gorm:"type:uuid;uniqueIndex:idx_account_material" json:"account_id,omitempty"`
317
+ AcademyId uuid.UUID `gorm:"type:uuid;index" json:"academy_id,omitempty"`
318
+ MaterialId uuid.UUID `gorm:"type:uuid;uniqueIndex:idx_account_material" json:"material_id,omitempty"`
319
  Progress float64 `gorm:"default:0" json:"progress,omitempty"`
320
  TotalCompletedContents uint `gorm:"default:0" json:"total_completed_contents,omitempty"`
321
  Status string `gorm:"type:varchar(50);default:'not attempted'" json:"status,omitempty"`
 
325
  func (AcademyMaterialProgress) TableName() string { return "academy_material_progress" }
326
 
327
  type AcademyContentProgress struct {
328
+ Id uuid.UUID `gorm:"type:uuid;primaryKey" json:"id"`
329
+ AccountId uuid.UUID `gorm:"type:uuid;uniqueIndex:idx_account_content" json:"account_id,omitempty"`
330
+ AcademyId uuid.UUID `gorm:"type:uuid;index" json:"academy_id,omitempty"`
331
+ MaterialId uuid.UUID `gorm:"type:uuid;index" json:"material_id,omitempty"`
332
+ ContentId uuid.UUID `gorm:"type:uuid;uniqueIndex:idx_account_content" json:"content_id,omitempty"`
333
  Status string `gorm:"type:varchar(50);default:'not attempted'" json:"status,omitempty"`
334
  CompletedAt *time.Time `json:"completed_at"`
335
  }
provider/provider.go CHANGED
@@ -5,6 +5,7 @@ import (
5
  "strings"
6
  entity "abdanhafidz.com/go-boilerplate/models/entity"
7
  "github.com/gin-gonic/gin"
 
8
  )
9
 
10
  type AppProvider interface {
@@ -30,16 +31,16 @@ func NewAppProvider() AppProvider {
30
  configProvider := NewConfigProvider()
31
  repositoriesProvider := NewRepositoriesProvider(configProvider)
32
  supabaseCfg := configProvider.ProvideSupabaseConfig()
33
- if supabaseCfg.URL == "" || supabaseCfg.ServiceKey == "" || supabaseCfg.BucketName == "" {
34
  log.Fatalf("Supabase configuration is invalid: URL, ServiceKey, or BucketName is empty")
35
  }
36
- if !strings.HasPrefix(supabaseCfg.URL, "https://") || !strings.Contains(supabaseCfg.URL, ".supabase.co") {
37
  log.Fatalf("Supabase URL is invalid")
38
  }
39
- if strings.Count(supabaseCfg.ServiceKey, ".") != 2 {
40
  log.Fatalf("Supabase service key is not a valid compact JWS")
41
  }
42
- storageDriver := NewSupabaseStorage(supabaseCfg.URL, supabaseCfg.ServiceKey, supabaseCfg.BucketName)
43
  servicesProvider := NewServicesProvider(repositoriesProvider, configProvider, storageDriver)
44
  controllerProvider := NewControllerProvider(servicesProvider)
45
  middlewareProvider := NewMiddlewareProvider(servicesProvider)
@@ -129,4 +130,4 @@ func (a *appProvider) ProvideControllers() ControllerProvider {
129
 
130
  func (a *appProvider) ProvideMiddlewares() MiddlewareProvider {
131
  return a.middlewareProvider
132
- }
 
5
  "strings"
6
  entity "abdanhafidz.com/go-boilerplate/models/entity"
7
  "github.com/gin-gonic/gin"
8
+ "abdanhafidz.com/go-boilerplate/services"
9
  )
10
 
11
  type AppProvider interface {
 
31
  configProvider := NewConfigProvider()
32
  repositoriesProvider := NewRepositoriesProvider(configProvider)
33
  supabaseCfg := configProvider.ProvideSupabaseConfig()
34
+ if supabaseCfg.GetURL() == "" || supabaseCfg.GetServiceKey() == "" || supabaseCfg.GetBucketName() == "" {
35
  log.Fatalf("Supabase configuration is invalid: URL, ServiceKey, or BucketName is empty")
36
  }
37
+ if !strings.HasPrefix(supabaseCfg.GetURL(), "https://") || !strings.Contains(supabaseCfg.GetURL(), ".supabase.co") {
38
  log.Fatalf("Supabase URL is invalid")
39
  }
40
+ if strings.Count(supabaseCfg.GetServiceKey(), ".") != 2 {
41
  log.Fatalf("Supabase service key is not a valid compact JWS")
42
  }
43
+ storageDriver := services.NewSupabaseStorageService(supabaseCfg.GetURL(), supabaseCfg.GetServiceKey(), supabaseCfg.GetBucketName())
44
  servicesProvider := NewServicesProvider(repositoriesProvider, configProvider, storageDriver)
45
  controllerProvider := NewControllerProvider(servicesProvider)
46
  middlewareProvider := NewMiddlewareProvider(servicesProvider)
 
130
 
131
  func (a *appProvider) ProvideMiddlewares() MiddlewareProvider {
132
  return a.middlewareProvider
133
+ }
provider/services_provider.go CHANGED
@@ -1,7 +1,8 @@
1
  package provider
2
 
3
  import (
4
- "abdanhafidz.com/go-boilerplate/services"
 
5
  )
6
 
7
  type ServicesProvider interface {
@@ -16,7 +17,7 @@ type ServicesProvider interface {
16
  ProvideForgotPasswordService() services.ForgotPasswordService
17
  ProvideEmailVerificationService() services.EmailVerificationService
18
  ProvideExternalAuthService() services.ExternalAuthService
19
- ProvideUploadService() *services.UploadService
20
  ProvideAcademyExamService() services.AcademyExamService
21
  }
22
 
@@ -32,14 +33,14 @@ type servicesProvider struct {
32
  forgotPasswordService services.ForgotPasswordService
33
  emailVerificationService services.EmailVerificationService
34
  externalAuthService services.ExternalAuthService
35
- uploadService *services.UploadService
36
  academyExamService services.AcademyExamService
37
  }
38
 
39
  func NewServicesProvider(
40
  repoProvider RepositoriesProvider,
41
  configProvider ConfigProvider,
42
- storageProvider services.StorageProvider,
43
  ) ServicesProvider {
44
 
45
  eventService := services.NewEventService(repoProvider.ProvideEventsRepository(), repoProvider.ProvideEventAssignRepository())
@@ -66,6 +67,7 @@ func NewServicesProvider(
66
  uploadService := services.NewUploadService(
67
  storageProvider,
68
  repoProvider.ProvideFileRepository(),
 
69
  )
70
 
71
  return &servicesProvider{
@@ -119,8 +121,8 @@ func (s *servicesProvider) ProvideExternalAuthService() services.ExternalAuthSer
119
  return s.externalAuthService
120
  }
121
 
122
- func (s *servicesProvider) ProvideUploadService() *services.UploadService {
123
  return s.uploadService
124
  }
125
 
126
- func (s *servicesProvider) ProvideAcademyExamService() services.AcademyExamService { return s.academyExamService }
 
1
  package provider
2
 
3
  import (
4
+ "abdanhafidz.com/go-boilerplate/config"
5
+ "abdanhafidz.com/go-boilerplate/services"
6
  )
7
 
8
  type ServicesProvider interface {
 
17
  ProvideForgotPasswordService() services.ForgotPasswordService
18
  ProvideEmailVerificationService() services.EmailVerificationService
19
  ProvideExternalAuthService() services.ExternalAuthService
20
+ ProvideUploadService() services.UploadService
21
  ProvideAcademyExamService() services.AcademyExamService
22
  }
23
 
 
33
  forgotPasswordService services.ForgotPasswordService
34
  emailVerificationService services.EmailVerificationService
35
  externalAuthService services.ExternalAuthService
36
+ uploadService services.UploadService
37
  academyExamService services.AcademyExamService
38
  }
39
 
40
  func NewServicesProvider(
41
  repoProvider RepositoriesProvider,
42
  configProvider ConfigProvider,
43
+ storageProvider StorageProvider,
44
  ) ServicesProvider {
45
 
46
  eventService := services.NewEventService(repoProvider.ProvideEventsRepository(), repoProvider.ProvideEventAssignRepository())
 
67
  uploadService := services.NewUploadService(
68
  storageProvider,
69
  repoProvider.ProvideFileRepository(),
70
+ config.NewUploadConfig(),
71
  )
72
 
73
  return &servicesProvider{
 
121
  return s.externalAuthService
122
  }
123
 
124
+ func (s *servicesProvider) ProvideUploadService() services.UploadService {
125
  return s.uploadService
126
  }
127
 
128
+ func (s *servicesProvider) ProvideAcademyExamService() services.AcademyExamService { return s.academyExamService }
provider/storage_provider.go ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ package provider
2
+
3
+ import (
4
+ "context"
5
+ "io"
6
+ )
7
+
8
+ type StorageProvider interface {
9
+ UploadFile(ctx context.Context, file io.Reader, destinationPath string, contentType string) (string, error)
10
+ }
provider/supabase_storage.go DELETED
@@ -1,47 +0,0 @@
1
- package provider
2
-
3
- import (
4
- "context"
5
- "fmt"
6
- "io"
7
- "mime/multipart"
8
- storage_go "github.com/supabase-community/storage-go"
9
- )
10
-
11
- type StorageProvider interface {
12
- UploadFile(ctx context.Context, file multipart.File, header *multipart.FileHeader) (string, error)
13
- GetFileURL(path string) (string, error)
14
- DeleteFile(path string) error
15
- }
16
-
17
- type SupabaseStorage struct {
18
- client *storage_go.Client
19
- bucketName string
20
- url string
21
- }
22
-
23
- func NewSupabaseStorage(url string, key string, bucketName string) *SupabaseStorage {
24
- client := storage_go.NewClient(url+"/storage/v1", key, nil)
25
- return &SupabaseStorage{
26
- client: client,
27
- bucketName: bucketName,
28
- url: url,
29
- }
30
- }
31
-
32
- func (s *SupabaseStorage) UploadFile(ctx context.Context, file io.Reader, destinationPath string, contentType string) (string, error) {
33
- _, err := s.client.UploadFile(s.bucketName, destinationPath, file, storage_go.FileOptions{
34
- ContentType: &contentType,
35
- Upsert: new(bool),
36
- })
37
-
38
- if err != nil {
39
- return "", err
40
- }
41
- publicURL := s.client.GetPublicUrl(s.bucketName, destinationPath).SignedURL
42
- if publicURL == "" {
43
- publicURL = fmt.Sprintf("%s/storage/v1/object/public/%s/%s", s.url, s.bucketName, destinationPath)
44
- }
45
-
46
- return publicURL, nil
47
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
repositories/academy_repository.go CHANGED
@@ -58,9 +58,6 @@ type AcademyRepository interface {
58
  DecrementContentOrdersGreaterThan(ctx context.Context, materialId uuid.UUID, order uint) error
59
  GetAccumulatedMaterialProgress(ctx context.Context, accountId uuid.UUID, academyId uuid.UUID) (float64, error)
60
 
61
- BatchRecalculateMaterialProgress(ctx context.Context, materialId uuid.UUID) error
62
- BatchRecalculateAcademyProgress(ctx context.Context, academyId uuid.UUID) error
63
-
64
  GetMaterialProgressBatch(ctx context.Context, accountId uuid.UUID, academyId uuid.UUID, materialIds []uuid.UUID) (map[uuid.UUID]entity.AcademyMaterialProgress, error)
65
  GetContentProgressBatch(ctx context.Context, accountId uuid.UUID, academyId uuid.UUID, contentIds []uuid.UUID) (map[uuid.UUID]entity.AcademyContentProgress, error)
66
  }
@@ -107,7 +104,7 @@ func (r *academyRepository) GetAcademyWithProgress(ctx context.Context, accountI
107
  if err != nil {
108
  return a, err
109
  }
110
- a.AcademyProgresss = ap
111
  return a, nil
112
  }
113
 
@@ -152,9 +149,9 @@ func (r *academyRepository) ListAcademy(ctx context.Context, accountId uuid.UUID
152
 
153
  for i := range list {
154
  if p, exists := progressMap[list[i].Id]; exists {
155
- list[i].AcademyProgresss = p
156
  } else {
157
- list[i].AcademyProgresss = entity.AcademyProgress{
158
  AccountId: accountId,
159
  AcademyId: list[i].Id,
160
  Status: entity.StatusNotStarted,
@@ -289,7 +286,7 @@ func (r *academyRepository) GetAcademyProgress(ctx context.Context, accountId uu
289
 
290
  func (r *academyRepository) UpsertAcademyProgress(ctx context.Context, p entity.AcademyProgress) (entity.AcademyProgress, error) {
291
  return p, r.db.WithContext(ctx).Clauses(clause.OnConflict{
292
- Columns: []clause.Column{{Name: "id"}},
293
  UpdateAll: true,
294
  }).Save(&p).Error
295
  }
@@ -311,7 +308,7 @@ func (r *academyRepository) GetMaterialProgress(ctx context.Context, accountId u
311
 
312
  func (r *academyRepository) UpsertMaterialProgress(ctx context.Context, p entity.AcademyMaterialProgress) (entity.AcademyMaterialProgress, error) {
313
  return p, r.db.WithContext(ctx).Clauses(clause.OnConflict{
314
- Columns: []clause.Column{{Name: "id"}},
315
  UpdateAll: true,
316
  }).Save(&p).Error
317
  }
@@ -334,7 +331,7 @@ func (r *academyRepository) GetContentProgress(ctx context.Context, accountId uu
334
 
335
  func (r *academyRepository) UpsertContentProgress(ctx context.Context, p entity.AcademyContentProgress) (entity.AcademyContentProgress, error) {
336
  return p, r.db.WithContext(ctx).Clauses(clause.OnConflict{
337
- Columns: []clause.Column{{Name: "id"}},
338
  UpdateAll: true,
339
  }).Save(&p).Error
340
  }
@@ -396,96 +393,6 @@ func (r *academyRepository) GetAccumulatedMaterialProgress(ctx context.Context,
396
  return total, err
397
  }
398
 
399
- func (r *academyRepository) BatchRecalculateMaterialProgress(ctx context.Context, materialId uuid.UUID) error {
400
- totalContents, err := r.CountContentsByMaterialID(ctx, materialId)
401
- if err != nil {
402
- return err
403
- }
404
-
405
- if totalContents == 0 {
406
- return r.db.WithContext(ctx).Model(&entity.AcademyMaterialProgress{}).
407
- Where("material_id = ?", materialId).
408
- Updates(map[string]interface{}{
409
- "progress": 100,
410
- "status": entity.StatusCompleted,
411
- "total_completed_contents": 0,
412
- }).Error
413
- }
414
-
415
- return r.db.WithContext(ctx).Exec(`
416
- UPDATE academy_material_progresses amp
417
- SET
418
- total_completed_contents = (
419
- SELECT COUNT(id) FROM academy_content_progresses acp
420
- WHERE acp.material_id = amp.material_id AND acp.account_id = amp.account_id AND acp.status = 'COMPLETED'
421
- ),
422
- progress = (
423
- SELECT COUNT(id) FROM academy_content_progresses acp
424
- WHERE acp.material_id = amp.material_id AND acp.account_id = amp.account_id AND acp.status = 'COMPLETED'
425
- )::float / ? * 100,
426
- status = CASE
427
- WHEN (
428
- SELECT COUNT(id) FROM academy_content_progresses acp
429
- WHERE acp.material_id = amp.material_id AND acp.account_id = amp.account_id AND acp.status = 'COMPLETED'
430
- ) >= ? THEN 'COMPLETED'
431
- ELSE 'IN_PROGRESS'
432
- END,
433
- completed_at = CASE
434
- WHEN (
435
- SELECT COUNT(id) FROM academy_content_progresses acp
436
- WHERE acp.material_id = amp.material_id AND acp.account_id = amp.account_id AND acp.status = 'COMPLETED'
437
- ) >= ? THEN NOW()
438
- ELSE NULL
439
- END
440
- WHERE amp.material_id = ?
441
- `, totalContents, totalContents, totalContents, materialId).Error
442
- }
443
-
444
- func (r *academyRepository) BatchRecalculateAcademyProgress(ctx context.Context, academyId uuid.UUID) error {
445
- totalMaterials, err := r.CountMaterialsByAcademyID(ctx, academyId)
446
- if err != nil {
447
- return err
448
- }
449
-
450
- if totalMaterials == 0 {
451
- return r.db.WithContext(ctx).Model(&entity.AcademyProgress{}).
452
- Where("academy_id = ?", academyId).
453
- Updates(map[string]interface{}{
454
- "progress": 100,
455
- "status": entity.StatusCompleted,
456
- "total_completed_materials": 0,
457
- }).Error
458
- }
459
-
460
- return r.db.WithContext(ctx).Exec(`
461
- UPDATE academy_progress ap
462
- SET
463
- progress = (
464
- SELECT COALESCE(SUM(amp.progress), 0) FROM academy_material_progresses amp
465
- WHERE amp.academy_id = ap.academy_id AND amp.account_id = ap.account_id
466
- )::float / ?,
467
- total_completed_materials = (
468
- SELECT COUNT(id) FROM academy_material_progresses amp
469
- WHERE amp.academy_id = ap.academy_id AND amp.account_id = ap.account_id AND amp.status = 'COMPLETED'
470
- ),
471
- status = CASE
472
- WHEN (
473
- SELECT COUNT(id) FROM academy_material_progresses amp
474
- WHERE amp.academy_id = ap.academy_id AND amp.account_id = ap.account_id AND amp.status = 'COMPLETED'
475
- ) >= ? THEN 'COMPLETED'
476
- ELSE 'IN_PROGRESS'
477
- END,
478
- completed_at = CASE
479
- WHEN (
480
- SELECT COUNT(id) FROM academy_material_progresses amp
481
- WHERE amp.academy_id = ap.academy_id AND amp.account_id = ap.account_id AND amp.status = 'COMPLETED'
482
- ) >= ? THEN NOW()
483
- ELSE NULL
484
- END
485
- WHERE ap.academy_id = ?
486
- `, totalMaterials, totalMaterials, totalMaterials, academyId).Error
487
- }
488
-
489
  func (r *academyRepository) GetMaterialProgressBatch(ctx context.Context, accountId uuid.UUID, academyId uuid.UUID, materialIds []uuid.UUID) (map[uuid.UUID]entity.AcademyMaterialProgress, error) {
490
  var progresses []entity.AcademyMaterialProgress
491
  result := r.db.WithContext(ctx).Where("account_id = ? AND academy_id = ? AND material_id IN ?", accountId, academyId, materialIds).Find(&progresses)
 
58
  DecrementContentOrdersGreaterThan(ctx context.Context, materialId uuid.UUID, order uint) error
59
  GetAccumulatedMaterialProgress(ctx context.Context, accountId uuid.UUID, academyId uuid.UUID) (float64, error)
60
 
 
 
 
61
  GetMaterialProgressBatch(ctx context.Context, accountId uuid.UUID, academyId uuid.UUID, materialIds []uuid.UUID) (map[uuid.UUID]entity.AcademyMaterialProgress, error)
62
  GetContentProgressBatch(ctx context.Context, accountId uuid.UUID, academyId uuid.UUID, contentIds []uuid.UUID) (map[uuid.UUID]entity.AcademyContentProgress, error)
63
  }
 
104
  if err != nil {
105
  return a, err
106
  }
107
+ a.AcademyProgress = ap
108
  return a, nil
109
  }
110
 
 
149
 
150
  for i := range list {
151
  if p, exists := progressMap[list[i].Id]; exists {
152
+ list[i].AcademyProgress = p
153
  } else {
154
+ list[i].AcademyProgress = entity.AcademyProgress{
155
  AccountId: accountId,
156
  AcademyId: list[i].Id,
157
  Status: entity.StatusNotStarted,
 
286
 
287
  func (r *academyRepository) UpsertAcademyProgress(ctx context.Context, p entity.AcademyProgress) (entity.AcademyProgress, error) {
288
  return p, r.db.WithContext(ctx).Clauses(clause.OnConflict{
289
+ Columns: []clause.Column{{Name: "account_id"}, {Name: "academy_id"}},
290
  UpdateAll: true,
291
  }).Save(&p).Error
292
  }
 
308
 
309
  func (r *academyRepository) UpsertMaterialProgress(ctx context.Context, p entity.AcademyMaterialProgress) (entity.AcademyMaterialProgress, error) {
310
  return p, r.db.WithContext(ctx).Clauses(clause.OnConflict{
311
+ Columns: []clause.Column{{Name: "account_id"}, {Name: "material_id"}},
312
  UpdateAll: true,
313
  }).Save(&p).Error
314
  }
 
331
 
332
  func (r *academyRepository) UpsertContentProgress(ctx context.Context, p entity.AcademyContentProgress) (entity.AcademyContentProgress, error) {
333
  return p, r.db.WithContext(ctx).Clauses(clause.OnConflict{
334
+ Columns: []clause.Column{{Name: "account_id"}, {Name: "content_id"}},
335
  UpdateAll: true,
336
  }).Save(&p).Error
337
  }
 
393
  return total, err
394
  }
395
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  func (r *academyRepository) GetMaterialProgressBatch(ctx context.Context, accountId uuid.UUID, academyId uuid.UUID, materialIds []uuid.UUID) (map[uuid.UUID]entity.AcademyMaterialProgress, error) {
397
  var progresses []entity.AcademyMaterialProgress
398
  result := r.db.WithContext(ctx).Where("account_id = ? AND academy_id = ? AND material_id IN ?", accountId, academyId, materialIds).Find(&progresses)
services/academy_service.go CHANGED
@@ -172,9 +172,7 @@ func (s *academyService) CreateMaterial(ctx context.Context, req dto.CreateMater
172
  return err
173
  }
174
 
175
- if err := txRepo.BatchRecalculateAcademyProgress(ctx, req.AcademyId); err != nil {
176
- return err
177
- }
178
 
179
  return nil
180
  })
@@ -223,9 +221,7 @@ func (s *academyService) DeleteMaterial(ctx context.Context, id uuid.UUID) error
223
  return err
224
  }
225
 
226
- if err := txRepo.BatchRecalculateAcademyProgress(ctx, m.AcademyId); err != nil {
227
- return err
228
- }
229
 
230
  return nil
231
  })
@@ -268,12 +264,7 @@ func (s *academyService) CreateContent(ctx context.Context, req dto.CreateConten
268
  return err
269
  }
270
 
271
- if err := txRepo.BatchRecalculateMaterialProgress(ctx, req.MaterialId); err != nil {
272
- return err
273
- }
274
- if err := txRepo.BatchRecalculateAcademyProgress(ctx, m.AcademyId); err != nil {
275
- return err
276
- }
277
 
278
  return nil
279
  })
@@ -315,20 +306,13 @@ func (s *academyService) DeleteContent(ctx context.Context, id uuid.UUID) error
315
  if _, err := txRepo.UpdateMaterial(ctx, material); err != nil {
316
  return err
317
  }
318
- if err := txRepo.BatchRecalculateMaterialProgress(ctx, c.MaterialId); err != nil {
319
- return err
320
- }
321
-
322
- // Update Academy juga
323
- if err := txRepo.BatchRecalculateAcademyProgress(ctx, material.AcademyId); err != nil {
324
- return err
325
- }
326
 
327
  return nil
328
  })
329
  }
330
 
331
- // ================= UPDATE PROGRESS =================
332
 
333
  func (s *academyService) UpdateContentProgress(ctx context.Context, accountId uuid.UUID, academySlug string, materialSlug string, order uint) (entity.AcademyContentProgress, entity.AcademyMaterialProgress, entity.AcademyProgress, error) {
334
  academy, err := s.academyRepo.GetAcademyBySlug(ctx, academySlug)
@@ -383,7 +367,7 @@ func (s *academyService) UpdateContentProgress(ctx context.Context, accountId uu
383
  if totalContentsCompleted >= m.ContentsCount {
384
  matStatus = entity.StatusCompleted
385
  matCompletedAt = utils.Ptr(time.Now())
386
- progressPct = 100
387
  }
388
  } else {
389
  matStatus = entity.StatusCompleted
@@ -411,7 +395,7 @@ func (s *academyService) UpdateContentProgress(ctx context.Context, accountId uu
411
  }
412
 
413
  accumulatedProgress, _ := txRepo.GetAccumulatedMaterialProgress(ctx, accountId, academy.Id)
414
- a, _ := txRepo.GetAcademyByID(ctx, academy.Id)
415
 
416
  acadStatus := entity.StatusNotStarted
417
  var acadCompletedAt *time.Time
@@ -467,7 +451,11 @@ func (s *academyService) GetAcademyResponse(ctx context.Context, accountId uuid.
467
  if err != nil {
468
  return nil, http_error.ACADEMY_NOT_FOUND
469
  }
470
- academyProgress, _ := s.academyRepo.GetAcademyProgress(ctx, accountId, academy.Id)
 
 
 
 
471
  materials, err := s.academyRepo.GetMaterialsWithContents(ctx, academy.Id)
472
  if err != nil {
473
  materials = []entity.AcademyMaterial{}
@@ -489,7 +477,16 @@ func (s *academyService) GetMaterialResponse(ctx context.Context, accountId uuid
489
  if err != nil {
490
  return nil, http_error.MATERIAL_NOT_FOUND
491
  }
492
- materialProgress, _ := s.academyRepo.GetMaterialProgress(ctx, accountId, academy.Id, material.Id)
 
 
 
 
 
 
 
 
 
493
  _, contents, err := s.academyRepo.GetMaterialWithContents(ctx, material.Id)
494
  if err != nil {
495
  contents = []entity.AcademyContent{}
 
172
  return err
173
  }
174
 
175
+ // progress recalculation handled on get
 
 
176
 
177
  return nil
178
  })
 
221
  return err
222
  }
223
 
224
+ // progress recalculation handled on get
 
 
225
 
226
  return nil
227
  })
 
264
  return err
265
  }
266
 
267
+ // progress recalculation handled on get
 
 
 
 
 
268
 
269
  return nil
270
  })
 
306
  if _, err := txRepo.UpdateMaterial(ctx, material); err != nil {
307
  return err
308
  }
309
+ // progress recalculation handled on get
 
 
 
 
 
 
 
310
 
311
  return nil
312
  })
313
  }
314
 
315
+ // ================= UPDATE PROGRESS =================
316
 
317
  func (s *academyService) UpdateContentProgress(ctx context.Context, accountId uuid.UUID, academySlug string, materialSlug string, order uint) (entity.AcademyContentProgress, entity.AcademyMaterialProgress, entity.AcademyProgress, error) {
318
  academy, err := s.academyRepo.GetAcademyBySlug(ctx, academySlug)
 
367
  if totalContentsCompleted >= m.ContentsCount {
368
  matStatus = entity.StatusCompleted
369
  matCompletedAt = utils.Ptr(time.Now())
370
+ progressPct = 100
371
  }
372
  } else {
373
  matStatus = entity.StatusCompleted
 
395
  }
396
 
397
  accumulatedProgress, _ := txRepo.GetAccumulatedMaterialProgress(ctx, accountId, academy.Id)
398
+ a, _ := txRepo.GetAcademyByID(ctx, academy.Id)
399
 
400
  acadStatus := entity.StatusNotStarted
401
  var acadCompletedAt *time.Time
 
451
  if err != nil {
452
  return nil, http_error.ACADEMY_NOT_FOUND
453
  }
454
+ accumulatedProgress, _ := s.academyRepo.GetAccumulatedMaterialProgress(ctx, accountId, academy.Id)
455
+ academyProgress := s.calculateAcademyProgress(ctx, s.academyRepo, accountId, academy.Id, accumulatedProgress, academy.MaterialsCount)
456
+ if _, err := s.academyRepo.UpsertAcademyProgress(ctx, academyProgress); err != nil {
457
+ return nil, err
458
+ }
459
  materials, err := s.academyRepo.GetMaterialsWithContents(ctx, academy.Id)
460
  if err != nil {
461
  materials = []entity.AcademyMaterial{}
 
477
  if err != nil {
478
  return nil, http_error.MATERIAL_NOT_FOUND
479
  }
480
+ totalCompleted, _ := s.academyRepo.CountCompletedContentsByMaterialAndAccount(ctx, accountId, material.Id)
481
+ materialProgress := s.calculateMaterialProgress(ctx, s.academyRepo, accountId, academy.Id, material.Id, totalCompleted, material.ContentsCount)
482
+ if _, err := s.academyRepo.UpsertMaterialProgress(ctx, materialProgress); err != nil {
483
+ return nil, err
484
+ }
485
+ accumulatedProgress, _ := s.academyRepo.GetAccumulatedMaterialProgress(ctx, accountId, academy.Id)
486
+ academyProgress := s.calculateAcademyProgress(ctx, s.academyRepo, accountId, academy.Id, accumulatedProgress, academy.MaterialsCount)
487
+ if _, err := s.academyRepo.UpsertAcademyProgress(ctx, academyProgress); err != nil {
488
+ return nil, err
489
+ }
490
  _, contents, err := s.academyRepo.GetMaterialWithContents(ctx, material.Id)
491
  if err != nil {
492
  contents = []entity.AcademyContent{}
services/academy_service_helpers.go CHANGED
@@ -39,11 +39,15 @@ func (s *academyService) getProgressStatus(progress float64, completed, total in
39
  }
40
 
41
  func (s *academyService) upsertContentProgressSimplified(
42
- ctx context.Context,
43
- txRepo repositories.AcademyRepository,
44
- accountId, academyId, materialId, contentId uuid.UUID,
45
- ) entity.AcademyContentProgress {
46
- existing, _ := txRepo.GetContentProgress(ctx, accountId, academyId, materialId, contentId)
 
 
 
 
47
 
48
  acp := entity.AcademyContentProgress{
49
  Id: s.getOrCreateID(existing.Id),
@@ -55,8 +59,10 @@ func (s *academyService) upsertContentProgressSimplified(
55
  CompletedAt: utils.Ptr(time.Now()),
56
  }
57
 
58
- txRepo.UpsertContentProgress(ctx, acp)
59
- return acp
 
 
60
  }
61
 
62
  func (s *academyService) calculateMaterialProgress(
@@ -125,26 +131,24 @@ func (s *academyService) calculateAcademyProgress(
125
  }
126
 
127
  func (s *academyService) updateAcademyMaterialCount(ctx context.Context, txRepo repositories.AcademyRepository, academyId uuid.UUID) error {
128
- count, err := txRepo.CountMaterialsByAcademyID(ctx, academyId)
129
- if err != nil {
130
- return err
131
- }
132
- academy, _ := txRepo.GetAcademyByID(ctx, academyId)
133
- academy.MaterialsCount = count
134
- _, err = txRepo.UpdateAcademy(ctx, academy)
135
- return err
136
  }
137
 
138
 
139
  func (s *academyService) updateMaterialContentCount(ctx context.Context, txRepo repositories.AcademyRepository, materialId uuid.UUID) error {
140
- count, err := txRepo.CountContentsByMaterialID(ctx, materialId)
141
- if err != nil {
142
- return err
143
- }
144
- material, _ := txRepo.GetMaterialByID(ctx, materialId)
145
- material.ContentsCount = count
146
- _, err = txRepo.UpdateMaterial(ctx, material)
147
- return err
148
  }
149
 
150
  func formatTime(t *time.Time) *string {
 
39
  }
40
 
41
  func (s *academyService) upsertContentProgressSimplified(
42
+ ctx context.Context,
43
+ txRepo repositories.AcademyRepository,
44
+ accountId, academyId, materialId, contentId uuid.UUID,
45
+ ) (entity.AcademyContentProgress, error) {
46
+ existing, err := txRepo.GetContentProgress(ctx, accountId, academyId, materialId, contentId)
47
+ if err != nil {
48
+ // propagate non-not-found errors
49
+ return entity.AcademyContentProgress{}, err
50
+ }
51
 
52
  acp := entity.AcademyContentProgress{
53
  Id: s.getOrCreateID(existing.Id),
 
59
  CompletedAt: utils.Ptr(time.Now()),
60
  }
61
 
62
+ if _, err := txRepo.UpsertContentProgress(ctx, acp); err != nil {
63
+ return entity.AcademyContentProgress{}, err
64
+ }
65
+ return acp, nil
66
  }
67
 
68
  func (s *academyService) calculateMaterialProgress(
 
131
  }
132
 
133
  func (s *academyService) updateAcademyMaterialCount(ctx context.Context, txRepo repositories.AcademyRepository, academyId uuid.UUID) error {
134
+ count, err := txRepo.CountMaterialsByAcademyID(ctx, academyId)
135
+ if err != nil { return err }
136
+ academy, err := txRepo.GetAcademyByID(ctx, academyId)
137
+ if err != nil { return err }
138
+ academy.MaterialsCount = count
139
+ _, err = txRepo.UpdateAcademy(ctx, academy)
140
+ return err
 
141
  }
142
 
143
 
144
  func (s *academyService) updateMaterialContentCount(ctx context.Context, txRepo repositories.AcademyRepository, materialId uuid.UUID) error {
145
+ count, err := txRepo.CountContentsByMaterialID(ctx, materialId)
146
+ if err != nil { return err }
147
+ material, err := txRepo.GetMaterialByID(ctx, materialId)
148
+ if err != nil { return err }
149
+ material.ContentsCount = count
150
+ _, err = txRepo.UpdateMaterial(ctx, material)
151
+ return err
 
152
  }
153
 
154
  func formatTime(t *time.Time) *string {
services/account_service.go CHANGED
@@ -3,6 +3,7 @@ package services
3
  import (
4
  "context"
5
  "errors"
 
6
  "regexp"
7
  "strings"
8
 
@@ -37,7 +38,6 @@ func NewAccountService(jwtService JWTService, accountRepo repositories.AccountRe
37
  jwtService: jwtService,
38
  accountRepo: accountRepo,
39
  accountDetailRepo: accountDetailRepo,
40
-
41
  }
42
  }
43
 
@@ -68,7 +68,9 @@ func (s *accountService) Create(ctx context.Context, name string, email string,
68
  return entity.Account{}, err
69
  }
70
 
71
- _, _ = s.CreateEmptyDetail(ctx, created.Id)
 
 
72
 
73
  return created, nil
74
 
 
3
  import (
4
  "context"
5
  "errors"
6
+ "fmt"
7
  "regexp"
8
  "strings"
9
 
 
38
  jwtService: jwtService,
39
  accountRepo: accountRepo,
40
  accountDetailRepo: accountDetailRepo,
 
41
  }
42
  }
43
 
 
68
  return entity.Account{}, err
69
  }
70
 
71
+ if _, err := s.CreateEmptyDetail(ctx, created.Id); err != nil {
72
+ return entity.Account{}, fmt.Errorf("create empty detail: %w", err)
73
+ }
74
 
75
  return created, nil
76
 
services/supabase_storage_service.go ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ package services
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "io"
7
+ storage_go "github.com/supabase-community/storage-go"
8
+ )
9
+
10
+ type SupabaseStorageService struct {
11
+ client *storage_go.Client
12
+ bucketName string
13
+ url string
14
+ }
15
+
16
+ func NewSupabaseStorageService(url string, key string, bucketName string) *SupabaseStorageService {
17
+ client := storage_go.NewClient(url+"/storage/v1", key, nil)
18
+ return &SupabaseStorageService{ client: client, bucketName: bucketName, url: url }
19
+ }
20
+
21
+ func (s *SupabaseStorageService) UploadFile(ctx context.Context, file io.Reader, destinationPath string, contentType string) (string, error) {
22
+ _, err := s.client.UploadFile(s.bucketName, destinationPath, file, storage_go.FileOptions{ ContentType: &contentType, Upsert: new(bool) })
23
+ if err != nil { return "", err }
24
+ publicURL := s.client.GetPublicUrl(s.bucketName, destinationPath).SignedURL
25
+ if publicURL == "" { publicURL = fmt.Sprintf("%s/storage/v1/object/public/%s/%s", s.url, s.bucketName, destinationPath) }
26
+ return publicURL, nil
27
+ }
services/upload_service.go CHANGED
@@ -1,54 +1,45 @@
1
  package services
2
 
3
  import (
4
- "context"
5
- "errors"
6
- "fmt"
7
- "io"
8
- "mime/multipart"
9
- "path/filepath"
10
- "regexp"
11
- "strings"
12
- "time"
13
-
14
- "github.com/google/uuid"
15
-
16
- entity "abdanhafidz.com/go-boilerplate/models/entity"
17
- http_error "abdanhafidz.com/go-boilerplate/models/error"
 
18
  )
19
 
20
- const MB = 1024 * 1024
21
-
22
- type uploadConfig struct {
23
- MaxBytes int64
24
- AllowedExts map[string]bool
25
- PathPrefix string
26
- MaxCount int
27
- }
28
-
29
- type StorageProvider interface {
30
- UploadFile(ctx context.Context, file io.Reader, destinationPath string, contentType string) (string, error)
31
  }
32
 
33
- type FileRepository interface {
34
- Create(ctx context.Context, file *entity.File) error
35
- FindByID(ctx context.Context, id uuid.UUID) (*entity.File, error)
 
36
  }
37
 
38
- type UploadService struct {
39
- storageProvider StorageProvider
40
- fileRepo FileRepository
41
  }
42
 
43
- func NewUploadService(storage StorageProvider, repo FileRepository) *UploadService {
44
- return &UploadService{
45
- storageProvider: storage,
46
- fileRepo: repo,
47
- }
48
  }
49
 
50
- func (s *UploadService) UploadFiles(ctx context.Context, files []*multipart.FileHeader, uploadContext string, accountID uuid.UUID) ([]entity.File, error) {
51
- config, err := s.getUploadConfig(uploadContext)
52
  if err != nil {
53
  return nil, err
54
  }
@@ -81,7 +72,7 @@ func (s *UploadService) UploadFiles(ctx context.Context, files []*multipart.File
81
  return uploadedFiles, nil
82
  }
83
 
84
- func (s *UploadService) GetFileByID(ctx context.Context, fileID uuid.UUID, accountID uuid.UUID) (*entity.File, error) {
85
  file, err := s.fileRepo.FindByID(ctx, fileID)
86
  if err != nil {
87
  if errors.Is(err, http_error.NOT_FOUND_ERROR) {
@@ -97,7 +88,7 @@ func (s *UploadService) GetFileByID(ctx context.Context, fileID uuid.UUID, accou
97
  return file, nil
98
  }
99
 
100
- func (s *UploadService) processSingleFile(ctx context.Context, fileHeader *multipart.FileHeader, config uploadConfig, uploadContext string, accountID uuid.UUID) (*entity.File, error) {
101
  if err := s.validateFile(fileHeader, config); err != nil {
102
  return nil, err
103
  }
@@ -137,7 +128,7 @@ func (s *UploadService) processSingleFile(ctx context.Context, fileHeader *multi
137
  return fileEntity, nil
138
  }
139
 
140
- func (s *UploadService) validateFile(file *multipart.FileHeader, config uploadConfig) error {
141
  if file.Size == 0 || file.Size > config.MaxBytes {
142
  return http_error.FILE_TOO_LARGE
143
  }
@@ -155,7 +146,7 @@ func (s *UploadService) validateFile(file *multipart.FileHeader, config uploadCo
155
  return nil
156
  }
157
 
158
- func (s *UploadService) generateStoredFilename(originalName string, ext string) string {
159
  originalNameWithoutExt := strings.TrimSuffix(originalName, ext)
160
  reg := regexp.MustCompile("[^a-zA-Z0-9._-]+")
161
  sanitizedRawName := reg.ReplaceAllString(originalNameWithoutExt, "_")
@@ -165,7 +156,7 @@ func (s *UploadService) generateStoredFilename(originalName string, ext string)
165
  return fmt.Sprintf("%s-%d-%s%s", uniqueID.String(), timestamp, sanitizedRawName, ext)
166
  }
167
 
168
- func (s *UploadService) generateStoragePath(prefix, contextType, filename string, accountID uuid.UUID) string {
169
  switch contextType {
170
  case "submission":
171
  now := time.Now()
@@ -177,98 +168,48 @@ func (s *UploadService) generateStoragePath(prefix, contextType, filename string
177
  }
178
  }
179
 
180
- func (s *UploadService) getUploadConfig(contextType string) (uploadConfig, error) {
181
- codeExts := map[string]bool{".cpp": true, ".c": true, ".py": true, ".java": true, ".go": true, ".js": true, ".txt": true}
182
- imgExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".gif": true}
183
- docExts := map[string]bool{".pdf": true, ".doc": true, ".docx": true}
184
-
185
- allExts := make(map[string]bool)
186
- for k, v := range codeExts {
187
- allExts[k] = v
 
 
 
 
 
 
 
 
 
188
  }
189
- for k, v := range imgExts {
190
- allExts[k] = v
 
 
 
 
 
191
  }
192
- for k, v := range docExts {
193
- allExts[k] = v
 
 
 
 
 
 
 
 
 
194
  }
195
 
196
- switch contextType {
197
- case "image":
198
- return uploadConfig{
199
- MaxBytes: 10 * MB,
200
- AllowedExts: imgExts,
201
- PathPrefix: "images",
202
- MaxCount: 5,
203
- }, nil
204
- case "material":
205
- return uploadConfig{
206
- MaxBytes: 10 * MB,
207
- AllowedExts: docExts,
208
- PathPrefix: "materials",
209
- MaxCount: 1,
210
- }, nil
211
- case "submission":
212
- return uploadConfig{
213
- MaxBytes: 1 * MB,
214
- AllowedExts: codeExts,
215
- PathPrefix: "submissions",
216
- MaxCount: 1,
217
- }, nil
218
- case "general":
219
- return uploadConfig{
220
- MaxBytes: 5 * MB,
221
- AllowedExts: allExts,
222
- PathPrefix: "temp",
223
- MaxCount: 5,
224
- }, nil
225
- default:
226
- return uploadConfig{}, http_error.INVALID_DATA_PAYLOAD
227
  }
228
- }
229
 
230
- func (s *UploadService) UploadRawFile(ctx context.Context, reader io.Reader, originalName string, contentType string, uploadContext string, accountID uuid.UUID) (*entity.File, error) {
231
- config, err := s.getUploadConfig(uploadContext)
232
- if err != nil {
233
- return nil, http_error.BAD_REQUEST_ERROR
234
- }
235
-
236
- ext := strings.ToLower(strings.TrimSpace(filepath.Ext(originalName)))
237
- if ext == "" {
238
- if strings.Contains(contentType, "pdf") {
239
- ext = ".pdf"
240
- } else if strings.Contains(contentType, "png") {
241
- ext = ".png"
242
- } else if strings.Contains(contentType, "jpeg") || strings.Contains(contentType, "jpg") {
243
- ext = ".jpg"
244
- } else {
245
- ext = ".bin"
246
- }
247
- }
248
-
249
- storedFilename := s.generateStoredFilename(originalName, ext)
250
- storagePath := s.generateStoragePath(config.PathPrefix, uploadContext, storedFilename, accountID)
251
-
252
- publicURL, err := s.storageProvider.UploadFile(ctx, reader, storagePath, contentType)
253
- if err != nil {
254
- return nil, err
255
- }
256
-
257
- fileEntity := &entity.File{
258
- Id: uuid.New(),
259
- OriginalName: originalName,
260
- StoredName: storedFilename,
261
- MimeType: contentType,
262
- Size: 0,
263
- Path: publicURL,
264
- Context: uploadContext,
265
- AccountId: accountID,
266
- CreatedAt: time.Now(),
267
- }
268
-
269
- if err := s.fileRepo.Create(ctx, fileEntity); err != nil {
270
- return nil, err
271
- }
272
-
273
- return fileEntity, nil
274
- }
 
1
  package services
2
 
3
  import (
4
+ "context"
5
+ "errors"
6
+ "fmt"
7
+ "io"
8
+ "mime/multipart"
9
+ "path/filepath"
10
+ "regexp"
11
+ "strings"
12
+ "time"
13
+
14
+ "abdanhafidz.com/go-boilerplate/config"
15
+ entity "abdanhafidz.com/go-boilerplate/models/entity"
16
+ http_error "abdanhafidz.com/go-boilerplate/models/error"
17
+ "abdanhafidz.com/go-boilerplate/repositories"
18
+ "github.com/google/uuid"
19
  )
20
 
21
+ type UploadService interface {
22
+ UploadFiles(ctx context.Context, files []*multipart.FileHeader, uploadContext string, accountID uuid.UUID) ([]entity.File, error)
23
+ GetFileByID(ctx context.Context, fileID uuid.UUID, accountID uuid.UUID) (*entity.File, error)
24
+ UploadRawFile(ctx context.Context, reader io.Reader, originalName string, contentType string, uploadContext string, accountID uuid.UUID) (*entity.File, error)
 
 
 
 
 
 
 
25
  }
26
 
27
+ type uploadService struct {
28
+ storageProvider storageUploader
29
+ fileRepo repositories.FileRepository
30
+ cfg config.UploadConfig
31
  }
32
 
33
+ func NewUploadService(storage storageUploader, repo repositories.FileRepository, cfg config.UploadConfig) UploadService {
34
+ return &uploadService{storageProvider: storage, fileRepo: repo, cfg: cfg}
 
35
  }
36
 
37
+ type storageUploader interface {
38
+ UploadFile(ctx context.Context, file io.Reader, destinationPath string, contentType string) (string, error)
 
 
 
39
  }
40
 
41
+ func (s *uploadService) UploadFiles(ctx context.Context, files []*multipart.FileHeader, uploadContext string, accountID uuid.UUID) ([]entity.File, error) {
42
+ config, err := s.cfg.Get(uploadContext)
43
  if err != nil {
44
  return nil, err
45
  }
 
72
  return uploadedFiles, nil
73
  }
74
 
75
+ func (s *uploadService) GetFileByID(ctx context.Context, fileID uuid.UUID, accountID uuid.UUID) (*entity.File, error) {
76
  file, err := s.fileRepo.FindByID(ctx, fileID)
77
  if err != nil {
78
  if errors.Is(err, http_error.NOT_FOUND_ERROR) {
 
88
  return file, nil
89
  }
90
 
91
+ func (s *uploadService) processSingleFile(ctx context.Context, fileHeader *multipart.FileHeader, config config.UploadRule, uploadContext string, accountID uuid.UUID) (*entity.File, error) {
92
  if err := s.validateFile(fileHeader, config); err != nil {
93
  return nil, err
94
  }
 
128
  return fileEntity, nil
129
  }
130
 
131
+ func (s *uploadService) validateFile(file *multipart.FileHeader, config config.UploadRule) error {
132
  if file.Size == 0 || file.Size > config.MaxBytes {
133
  return http_error.FILE_TOO_LARGE
134
  }
 
146
  return nil
147
  }
148
 
149
+ func (s *uploadService) generateStoredFilename(originalName string, ext string) string {
150
  originalNameWithoutExt := strings.TrimSuffix(originalName, ext)
151
  reg := regexp.MustCompile("[^a-zA-Z0-9._-]+")
152
  sanitizedRawName := reg.ReplaceAllString(originalNameWithoutExt, "_")
 
156
  return fmt.Sprintf("%s-%d-%s%s", uniqueID.String(), timestamp, sanitizedRawName, ext)
157
  }
158
 
159
+ func (s *uploadService) generateStoragePath(prefix, contextType, filename string, accountID uuid.UUID) string {
160
  switch contextType {
161
  case "submission":
162
  now := time.Now()
 
168
  }
169
  }
170
 
171
+ func (s *uploadService) UploadRawFile(ctx context.Context, reader io.Reader, originalName string, contentType string, uploadContext string, accountID uuid.UUID) (*entity.File, error) {
172
+ rule, err := s.cfg.Get(uploadContext)
173
+ if err != nil {
174
+ return nil, http_error.BAD_REQUEST_ERROR
175
+ }
176
+
177
+ ext := strings.ToLower(strings.TrimSpace(filepath.Ext(originalName)))
178
+ if ext == "" {
179
+ if strings.Contains(contentType, "pdf") {
180
+ ext = ".pdf"
181
+ } else if strings.Contains(contentType, "png") {
182
+ ext = ".png"
183
+ } else if strings.Contains(contentType, "jpeg") || strings.Contains(contentType, "jpg") {
184
+ ext = ".jpg"
185
+ } else {
186
+ ext = ".bin"
187
+ }
188
  }
189
+
190
+ storedFilename := s.generateStoredFilename(originalName, ext)
191
+ storagePath := s.generateStoragePath(rule.PathPrefix, uploadContext, storedFilename, accountID)
192
+
193
+ publicURL, err := s.storageProvider.UploadFile(ctx, reader, storagePath, contentType)
194
+ if err != nil {
195
+ return nil, err
196
  }
197
+
198
+ fileEntity := &entity.File{
199
+ Id: uuid.New(),
200
+ OriginalName: originalName,
201
+ StoredName: storedFilename,
202
+ MimeType: contentType,
203
+ Size: 0,
204
+ Path: publicURL,
205
+ Context: uploadContext,
206
+ AccountId: accountID,
207
+ CreatedAt: time.Now(),
208
  }
209
 
210
+ if err := s.fileRepo.Create(ctx, fileEntity); err != nil {
211
+ return nil, err
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  }
 
213
 
214
+ return fileEntity, nil
215
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
space/.gitignore CHANGED
@@ -1 +1,2 @@
1
- /.env
 
 
1
+ /.env
2
+ go-boilerplate.exe