17 | )
18 |
19 | type EmailService struct {
20 | sender emailProvider.Sender
21 | config config.EmailConfig
22 | schools SchoolsService
23 |
24 | cache cache.Cache
25 |
26 | sendpulseClients map[primitive.ObjectID]*sendpulse.Client
27 | }
28 |
29 | // Structures used for templates.
30 | type verificationEmailInput struct {
31 | VerificationLink string
32 | }
33 |
34 | type purchaseSuccessfulEmailInput struct {
35 | Name string
36 | CourseName string
37 | }
38 |
39 | func NewEmailsService(sender emailProvider.Sender, config config.EmailConfig, schools SchoolsService, cache cache.Cache) *EmailService {
40 | return &EmailService{
41 | sender: sender,
42 | config: config,
43 | schools: schools,
44 | cache: cache,
45 | sendpulseClients: make(map[primitive.ObjectID]*sendpulse.Client),
46 | }
47 | }
48 |
49 | func (s *EmailService) SendStudentVerificationEmail(input VerificationEmailInput) error {
50 | subject := fmt.Sprintf(s.config.Subjects.Verification, input.Name)
51 |
52 | templateInput := verificationEmailInput{s.createVerificationLink(input.Domain, input.VerificationCode)}
53 | sendInput := emailProvider.SendEmailInput{Subject: subject, To: input.Email}
54 |
55 | if err := sendInput.GenerateBodyFromHTML(s.config.Templates.Verification, templateInput); err != nil {
56 | return err
57 | }
58 |
59 | return s.sender.Send(sendInput)
60 | }
61 |
62 | func (s *EmailService) SendStudentPurchaseSuccessfulEmail(input StudentPurchaseSuccessfulEmailInput) error {
63 | templateInput := purchaseSuccessfulEmailInput{Name: input.Name, CourseName: input.CourseName}
64 | sendInput := emailProvider.SendEmailInput{Subject: s.config.Subjects.PurchaseSuccessful, To: input.Email}
65 |
66 | if err := sendInput.GenerateBodyFromHTML(s.config.Templates.PurchaseSuccessful, templateInput); err != nil {
67 | return err
68 | }
69 |
70 | return s.sender.Send(sendInput)
71 | }
72 |
73 | func (s *EmailService) SendUserVerificationEmail(input VerificationEmailInput) error {
74 | // todo implement
75 | return nil
76 | }
77 |
78 | func (s *EmailService) createVerificationLink(domain, code string) string {
79 | return fmt.Sprintf(verificationLinkTmpl, domain, code)
80 | }
81 |
82 | func (s *EmailService) AddStudentToList(ctx context.Context, email, name string, schoolID primitive.ObjectID) error {
83 | // TODO refactor
84 | school, err := s.schools.GetById(ctx, schoolID)
85 | if err != nil {
86 | return err
87 | }
88 |
89 | if !school.Settings.SendPulse.Connected {
90 | return domain.ErrSendPulseIsNotConnected
91 | }
92 |
93 | client, ex := s.sendpulseClients[schoolID]
94 | if !ex {
95 | client = sendpulse.NewClient(school.Settings.SendPulse.ID, school.Settings.SendPulse.Secret, s.cache)
96 | s.sendpulseClients[schoolID] = client
97 | }
98 |
99 | return client.AddEmailToList(emailProvider.AddEmailInput{
100 | Email: email,
101 | ListID: school.Settings.SendPulse.ListID,
102 | Variables: map[string]string{
103 | "Name": name,
104 | "source": "registration",
105 | },
106 | })
107 | }
108 |
--------------------------------------------------------------------------------
/internal/service/files.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "os"
8 | "strings"
9 | "time"
10 |
11 | "github.com/zhashkevych/creatly-backend/internal/domain"
12 | "github.com/zhashkevych/creatly-backend/internal/repository"
13 | "github.com/zhashkevych/creatly-backend/pkg/logger"
14 | "go.mongodb.org/mongo-driver/bson/primitive"
15 | "go.mongodb.org/mongo-driver/mongo"
16 |
17 | "github.com/google/uuid"
18 | "github.com/zhashkevych/creatly-backend/pkg/storage"
19 | )
20 |
21 | const (
22 | _workersCount = 2
23 | _workerInterval = time.Second * 10
24 | )
25 |
26 | var folders = map[domain.FileType]string{
27 | domain.Image: "images",
28 | domain.Video: "videos",
29 | domain.Other: "other",
30 | }
31 |
32 | type FilesService struct {
33 | repo repository.Files
34 | storage storage.Provider
35 | env string
36 | }
37 |
38 | func NewFilesService(repo repository.Files, storage storage.Provider, env string) *FilesService {
39 | return &FilesService{repo: repo, storage: storage, env: env}
40 | }
41 |
42 | func (s *FilesService) Save(ctx context.Context, file domain.File) (primitive.ObjectID, error) {
43 | return s.repo.Create(ctx, file)
44 | }
45 |
46 | func (s *FilesService) UpdateStatus(ctx context.Context, fileName string, status domain.FileStatus) error {
47 | return s.repo.UpdateStatus(ctx, fileName, status)
48 | }
49 |
50 | func (s *FilesService) GetByID(ctx context.Context, id, schoolId primitive.ObjectID) (domain.File, error) {
51 | return s.repo.GetByID(ctx, id, schoolId)
52 | }
53 |
54 | func (s *FilesService) UploadAndSaveFile(ctx context.Context, file domain.File) (string, error) {
55 | defer removeFile(file.Name)
56 |
57 | file.UploadStartedAt = time.Now()
58 |
59 | if _, err := s.Save(ctx, file); err != nil {
60 | return "", err
61 | }
62 |
63 | return s.upload(ctx, file)
64 | }
65 |
66 | func (s *FilesService) InitStorageUploaderWorkers(ctx context.Context) {
67 | for i := 0; i < _workersCount; i++ {
68 | go s.processUploadToStorage(ctx)
69 | }
70 | }
71 |
72 | func (s *FilesService) processUploadToStorage(ctx context.Context) {
73 | for {
74 | if err := s.uploadToStorage(ctx); err != nil {
75 | logger.Error("uploadToStorage(): ", err)
76 | }
77 |
78 | time.Sleep(_workerInterval)
79 | }
80 | }
81 |
82 | func (s *FilesService) uploadToStorage(ctx context.Context) error {
83 | file, err := s.repo.GetForUploading(ctx)
84 | if err != nil {
85 | if errors.Is(err, mongo.ErrNoDocuments) {
86 | return nil
87 | }
88 |
89 | return err
90 | }
91 |
92 | defer removeFile(file.Name)
93 |
94 | logger.Infof("processing file %s", file.Name)
95 |
96 | url, err := s.upload(ctx, file)
97 | if err != nil {
98 | if err := s.repo.UpdateStatus(ctx, file.Name, domain.StorageUploadError); err != nil {
99 | return err
100 | }
101 |
102 | return err
103 | }
104 |
105 | logger.Infof("file %s processed successfully", file.Name)
106 |
107 | if err := s.repo.UpdateStatusAndSetURL(ctx, file.ID, url); err != nil {
108 | return err
109 | }
110 |
111 | return nil
112 | }
113 |
114 | func (s *FilesService) upload(ctx context.Context, file domain.File) (string, error) {
115 | f, err := os.Open(file.Name)
116 | if err != nil {
117 | return "", err
118 | }
119 |
120 | info, _ := f.Stat()
121 | logger.Infof("file info: %+v", info)
122 |
123 | defer f.Close()
124 |
125 | return s.storage.Upload(ctx, storage.UploadInput{
126 | File: f,
127 | Size: file.Size,
128 | ContentType: file.ContentType,
129 | Name: s.generateFilename(file),
130 | })
131 | }
132 |
133 | func (s *FilesService) generateFilename(file domain.File) string {
134 | filename := fmt.Sprintf("%s.%s", uuid.New().String(), getFileExtension(file.Name))
135 | folder := folders[file.Type]
136 |
137 | fileNameParts := strings.Split(file.Name, "-") // first part is schoolId
138 |
139 | return fmt.Sprintf("%s/%s/%s/%s", s.env, fileNameParts[0], folder, filename)
140 | }
141 |
142 | func getFileExtension(filename string) string {
143 | parts := strings.Split(filename, ".")
144 |
145 | return parts[len(parts)-1]
146 | }
147 |
148 | func removeFile(filename string) {
149 | if err := os.Remove(filename); err != nil {
150 | logger.Error("removeFile(): ", err)
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/internal/service/lessons.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 | "errors"
6 |
7 | "github.com/zhashkevych/creatly-backend/internal/domain"
8 | "github.com/zhashkevych/creatly-backend/internal/repository"
9 | "go.mongodb.org/mongo-driver/bson/primitive"
10 | "go.mongodb.org/mongo-driver/mongo"
11 | )
12 |
13 | type LessonsService struct {
14 | repo repository.Modules
15 | contentRepo repository.LessonContent
16 | }
17 |
18 | func NewLessonsService(repo repository.Modules, contentRepo repository.LessonContent) *LessonsService {
19 | return &LessonsService{repo: repo, contentRepo: contentRepo}
20 | }
21 |
22 | func (s *LessonsService) Create(ctx context.Context, inp AddLessonInput) (primitive.ObjectID, error) {
23 | schoolID, err := primitive.ObjectIDFromHex(inp.SchoolID)
24 | if err != nil {
25 | return primitive.ObjectID{}, err
26 | }
27 |
28 | lesson := domain.Lesson{
29 | ID: primitive.NewObjectID(),
30 | SchoolID: schoolID,
31 | Name: inp.Name,
32 | Position: inp.Position,
33 | }
34 |
35 | id, err := primitive.ObjectIDFromHex(inp.ModuleID)
36 | if err != nil {
37 | return primitive.ObjectID{}, err
38 | }
39 |
40 | if err := s.repo.AddLesson(ctx, schoolID, id, lesson); err != nil {
41 | return primitive.ObjectID{}, err
42 | }
43 |
44 | return lesson.ID, nil
45 | }
46 |
47 | func (s *LessonsService) GetById(ctx context.Context, lessonId primitive.ObjectID) (domain.Lesson, error) {
48 | module, err := s.repo.GetByLesson(ctx, lessonId)
49 | if err != nil {
50 | return domain.Lesson{}, err
51 | }
52 |
53 | var lesson domain.Lesson
54 |
55 | for _, l := range module.Lessons {
56 | if l.ID == lessonId {
57 | lesson = l
58 | }
59 | }
60 |
61 | content, err := s.contentRepo.GetByLesson(ctx, lessonId)
62 | if err != nil {
63 | if errors.Is(err, mongo.ErrNoDocuments) {
64 | return lesson, nil
65 | }
66 |
67 | return lesson, err
68 | }
69 |
70 | lesson.Content = content.Content
71 |
72 | return lesson, nil
73 | }
74 |
75 | func (s *LessonsService) Update(ctx context.Context, inp UpdateLessonInput) error {
76 | id, err := primitive.ObjectIDFromHex(inp.LessonID)
77 | if err != nil {
78 | return err
79 | }
80 |
81 | schoolID, err := primitive.ObjectIDFromHex(inp.SchoolID)
82 | if err != nil {
83 | return err
84 | }
85 |
86 | if inp.Name != "" || inp.Position != nil || inp.Published != nil {
87 | if err := s.repo.UpdateLesson(ctx, repository.UpdateLessonInput{
88 | ID: id,
89 | Name: inp.Name,
90 | Position: inp.Position,
91 | Published: inp.Published,
92 | SchoolID: schoolID,
93 | }); err != nil {
94 | return err
95 | }
96 | }
97 |
98 | if inp.Content != "" {
99 | if err := s.contentRepo.Update(ctx, schoolID, id, inp.Content); err != nil {
100 | return err
101 | }
102 | }
103 |
104 | return nil
105 | }
106 |
107 | func (s *LessonsService) Delete(ctx context.Context, schoolId, id primitive.ObjectID) error {
108 | return s.repo.DeleteLesson(ctx, schoolId, id)
109 | }
110 |
111 | func (s *LessonsService) DeleteContent(ctx context.Context, schoolId primitive.ObjectID, lessonIds []primitive.ObjectID) error {
112 | return s.contentRepo.DeleteContent(ctx, schoolId, lessonIds)
113 | }
114 |
--------------------------------------------------------------------------------
/internal/service/modules.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 | "sort"
6 |
7 | "github.com/zhashkevych/creatly-backend/internal/domain"
8 | "github.com/zhashkevych/creatly-backend/internal/repository"
9 | "go.mongodb.org/mongo-driver/bson/primitive"
10 | )
11 |
12 | type ModulesService struct {
13 | repo repository.Modules
14 | contentRepo repository.LessonContent
15 | }
16 |
17 | func NewModulesService(repo repository.Modules, contentRepo repository.LessonContent) *ModulesService {
18 | return &ModulesService{repo: repo, contentRepo: contentRepo}
19 | }
20 |
21 | func (s *ModulesService) GetPublishedByCourseId(ctx context.Context, courseId primitive.ObjectID) ([]domain.Module, error) {
22 | modules, err := s.repo.GetPublishedByCourseId(ctx, courseId)
23 | if err != nil {
24 | return nil, err
25 | }
26 |
27 | for i := range modules {
28 | sortLessons(modules[i].Lessons)
29 | }
30 |
31 | return modules, nil
32 | }
33 |
34 | func (s *ModulesService) GetByCourseId(ctx context.Context, courseId primitive.ObjectID) ([]domain.Module, error) {
35 | modules, err := s.repo.GetByCourseId(ctx, courseId)
36 | if err != nil {
37 | return nil, err
38 | }
39 |
40 | for i := range modules {
41 | sortLessons(modules[i].Lessons)
42 | }
43 |
44 | return modules, nil
45 | }
46 |
47 | func (s *ModulesService) GetById(ctx context.Context, moduleId primitive.ObjectID) (domain.Module, error) {
48 | module, err := s.repo.GetPublishedById(ctx, moduleId)
49 | if err != nil {
50 | return module, err
51 | }
52 |
53 | sortLessons(module.Lessons)
54 |
55 | return module, nil
56 | }
57 |
58 | func (s *ModulesService) GetWithContent(ctx context.Context, moduleID primitive.ObjectID) (domain.Module, error) {
59 | module, err := s.repo.GetById(ctx, moduleID)
60 | if err != nil {
61 | return module, err
62 | }
63 |
64 | lessonIds := make([]primitive.ObjectID, len(module.Lessons))
65 | publishedLessons := make([]domain.Lesson, 0)
66 |
67 | for _, lesson := range module.Lessons {
68 | if lesson.Published {
69 | publishedLessons = append(publishedLessons, lesson)
70 | lessonIds = append(lessonIds, lesson.ID)
71 | }
72 | }
73 |
74 | module.Lessons = publishedLessons // remove unpublished lessons from final result
75 |
76 | content, err := s.contentRepo.GetByLessons(ctx, lessonIds)
77 | if err != nil {
78 | return module, err
79 | }
80 |
81 | for i := range module.Lessons {
82 | for _, lessonContent := range content {
83 | if module.Lessons[i].ID == lessonContent.LessonID {
84 | module.Lessons[i].Content = lessonContent.Content
85 | }
86 | }
87 | }
88 |
89 | sortLessons(module.Lessons)
90 |
91 | return module, nil
92 | }
93 |
94 | func (s *ModulesService) GetByPackages(ctx context.Context, packageIds []primitive.ObjectID) ([]domain.Module, error) {
95 | modules, err := s.repo.GetByPackages(ctx, packageIds)
96 | if err != nil {
97 | return nil, err
98 | }
99 |
100 | for i := range modules {
101 | sortLessons(modules[i].Lessons)
102 | }
103 |
104 | return modules, nil
105 | }
106 |
107 | func (s *ModulesService) GetByLesson(ctx context.Context, lessonID primitive.ObjectID) (domain.Module, error) {
108 | return s.repo.GetByLesson(ctx, lessonID)
109 | }
110 |
111 | func (s *ModulesService) Create(ctx context.Context, inp CreateModuleInput) (primitive.ObjectID, error) {
112 | id, err := primitive.ObjectIDFromHex(inp.CourseID)
113 | if err != nil {
114 | return id, err
115 | }
116 |
117 | schoolID, err := primitive.ObjectIDFromHex(inp.SchoolID)
118 | if err != nil {
119 | return id, err
120 | }
121 |
122 | module := domain.Module{
123 | Name: inp.Name,
124 | Position: inp.Position,
125 | CourseID: id,
126 | SchoolID: schoolID,
127 | }
128 |
129 | return s.repo.Create(ctx, module)
130 | }
131 |
132 | func (s *ModulesService) Update(ctx context.Context, inp UpdateModuleInput) error {
133 | id, err := primitive.ObjectIDFromHex(inp.ID)
134 | if err != nil {
135 | return err
136 | }
137 |
138 | schoolID, err := primitive.ObjectIDFromHex(inp.SchoolID)
139 | if err != nil {
140 | return err
141 | }
142 |
143 | updateInput := repository.UpdateModuleInput{
144 | ID: id,
145 | SchoolID: schoolID,
146 | Name: inp.Name,
147 | Position: inp.Position,
148 | Published: inp.Published,
149 | }
150 |
151 | return s.repo.Update(ctx, updateInput)
152 | }
153 |
154 | func (s *ModulesService) Delete(ctx context.Context, schoolId, moduleId primitive.ObjectID) error {
155 | module, err := s.repo.GetById(ctx, moduleId)
156 | if err != nil {
157 | return err
158 | }
159 |
160 | if err := s.repo.Delete(ctx, schoolId, moduleId); err != nil {
161 | return err
162 | }
163 |
164 | lessonIds := make([]primitive.ObjectID, len(module.Lessons))
165 | for _, lesson := range module.Lessons {
166 | lessonIds = append(lessonIds, lesson.ID)
167 | }
168 |
169 | return s.contentRepo.DeleteContent(ctx, schoolId, lessonIds)
170 | }
171 |
172 | func (s *ModulesService) DeleteByCourse(ctx context.Context, schoolId, courseId primitive.ObjectID) error {
173 | modules, err := s.repo.GetPublishedByCourseId(ctx, courseId)
174 | if err != nil {
175 | return err
176 | }
177 |
178 | if err := s.repo.DeleteByCourse(ctx, schoolId, courseId); err != nil {
179 | return err
180 | }
181 |
182 | lessonIds := make([]primitive.ObjectID, 0)
183 |
184 | for _, module := range modules {
185 | for _, lesson := range module.Lessons {
186 | lessonIds = append(lessonIds, lesson.ID)
187 | }
188 | }
189 |
190 | return s.contentRepo.DeleteContent(ctx, schoolId, lessonIds)
191 | }
192 |
193 | func sortLessons(lessons []domain.Lesson) {
194 | sort.Slice(lessons, func(i, j int) bool {
195 | return lessons[i].Position < lessons[j].Position
196 | })
197 | }
198 |
--------------------------------------------------------------------------------
/internal/service/offers.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/zhashkevych/creatly-backend/internal/domain"
7 | "github.com/zhashkevych/creatly-backend/internal/repository"
8 | "go.mongodb.org/mongo-driver/bson/primitive"
9 | )
10 |
11 | type OffersService struct {
12 | repo repository.Offers
13 | modulesService Modules
14 | packagesService Packages
15 | }
16 |
17 | func NewOffersService(repo repository.Offers, modulesService Modules, packagesService Packages) *OffersService {
18 | return &OffersService{repo: repo, modulesService: modulesService, packagesService: packagesService}
19 | }
20 |
21 | func (s *OffersService) GetById(ctx context.Context, id primitive.ObjectID) (domain.Offer, error) {
22 | return s.repo.GetById(ctx, id)
23 | }
24 |
25 | func (s *OffersService) getByPackage(ctx context.Context, schoolId, packageId primitive.ObjectID) ([]domain.Offer, error) {
26 | offers, err := s.repo.GetBySchool(ctx, schoolId)
27 | if err != nil {
28 | return nil, err
29 | }
30 |
31 | result := make([]domain.Offer, 0)
32 |
33 | for _, offer := range offers {
34 | if inArray(offer.PackageIDs, packageId) {
35 | result = append(result, offer)
36 | }
37 | }
38 |
39 | return result, nil
40 | }
41 |
42 | func (s *OffersService) GetByModule(ctx context.Context, schoolId, moduleId primitive.ObjectID) ([]domain.Offer, error) {
43 | module, err := s.modulesService.GetById(ctx, moduleId)
44 | if err != nil {
45 | return nil, err
46 | }
47 |
48 | return s.getByPackage(ctx, schoolId, module.PackageID)
49 | }
50 |
51 | func (s *OffersService) GetByCourse(ctx context.Context, courseId primitive.ObjectID) ([]domain.Offer, error) {
52 | packages, err := s.packagesService.GetByCourse(ctx, courseId)
53 | if err != nil {
54 | return nil, err
55 | }
56 |
57 | if len(packages) == 0 {
58 | return []domain.Offer{}, nil
59 | }
60 |
61 | packageIds := make([]primitive.ObjectID, len(packages))
62 | for i, pkg := range packages {
63 | packageIds[i] = pkg.ID
64 | }
65 |
66 | return s.repo.GetByPackages(ctx, packageIds)
67 | }
68 |
69 | func (s *OffersService) Create(ctx context.Context, inp CreateOfferInput) (primitive.ObjectID, error) {
70 | if inp.PaymentMethod.UsesProvider {
71 | if err := inp.PaymentMethod.Validate(); err != nil {
72 | return primitive.ObjectID{}, err
73 | }
74 | }
75 |
76 | var (
77 | packageIDs []primitive.ObjectID
78 | err error
79 | )
80 |
81 | if inp.Packages != nil {
82 | packageIDs, err = stringArrayToObjectId(inp.Packages)
83 | if err != nil {
84 | return primitive.ObjectID{}, err
85 | }
86 | }
87 |
88 | return s.repo.Create(ctx, domain.Offer{
89 | SchoolID: inp.SchoolID,
90 | Name: inp.Name,
91 | Description: inp.Description,
92 | Benefits: inp.Benefits,
93 | Price: inp.Price,
94 | PaymentMethod: inp.PaymentMethod,
95 | PackageIDs: packageIDs,
96 | })
97 | }
98 |
99 | func (s *OffersService) GetAll(ctx context.Context, schoolId primitive.ObjectID) ([]domain.Offer, error) {
100 | return s.repo.GetBySchool(ctx, schoolId)
101 | }
102 |
103 | func (s *OffersService) Update(ctx context.Context, inp UpdateOfferInput) error {
104 | if err := inp.ValidatePayment(); err != nil {
105 | return err
106 | }
107 |
108 | id, err := primitive.ObjectIDFromHex(inp.ID)
109 | if err != nil {
110 | return err
111 | }
112 |
113 | schoolId, err := primitive.ObjectIDFromHex(inp.SchoolID)
114 | if err != nil {
115 | return err
116 | }
117 |
118 | updateInput := repository.UpdateOfferInput{
119 | ID: id,
120 | SchoolID: schoolId,
121 | Name: inp.Name,
122 | Description: inp.Description,
123 | Price: inp.Price,
124 | Benefits: inp.Benefits,
125 | PaymentMethod: inp.PaymentMethod,
126 | }
127 |
128 | if inp.Packages != nil {
129 | updateInput.Packages, err = stringArrayToObjectId(inp.Packages)
130 | if err != nil {
131 | return err
132 | }
133 | }
134 |
135 | return s.repo.Update(ctx, updateInput)
136 | }
137 |
138 | func (s *OffersService) Delete(ctx context.Context, schoolId, id primitive.ObjectID) error {
139 | return s.repo.Delete(ctx, schoolId, id)
140 | }
141 |
142 | func (s *OffersService) GetByIds(ctx context.Context, ids []primitive.ObjectID) ([]domain.Offer, error) {
143 | return s.repo.GetByIds(ctx, ids)
144 | }
145 |
146 | func inArray(array []primitive.ObjectID, searchedItem primitive.ObjectID) bool {
147 | for i := range array {
148 | if array[i] == searchedItem {
149 | return true
150 | }
151 | }
152 |
153 | return false
154 | }
155 |
--------------------------------------------------------------------------------
/internal/service/orders.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/zhashkevych/creatly-backend/internal/domain"
8 | "github.com/zhashkevych/creatly-backend/internal/repository"
9 | "go.mongodb.org/mongo-driver/bson/primitive"
10 | )
11 |
12 | type OrdersService struct {
13 | offersService Offers
14 | promoCodesService PromoCodes
15 | studentsService Students
16 |
17 | repo repository.Orders
18 | }
19 |
20 | func NewOrdersService(repo repository.Orders, offersService Offers, promoCodesService PromoCodes, studentsService Students) *OrdersService {
21 | return &OrdersService{
22 | repo: repo,
23 | offersService: offersService,
24 | promoCodesService: promoCodesService,
25 | studentsService: studentsService,
26 | }
27 | }
28 |
29 | func (s *OrdersService) Create(ctx context.Context, studentId, offerId, promocodeId primitive.ObjectID) (primitive.ObjectID, error) { //nolint:funlen
30 | offer, err := s.offersService.GetById(ctx, offerId)
31 | if err != nil {
32 | return primitive.ObjectID{}, err
33 | }
34 |
35 | promocode, err := s.getOrderPromocode(ctx, offer.SchoolID, promocodeId)
36 | if err != nil {
37 | return primitive.ObjectID{}, err
38 | }
39 |
40 | student, err := s.studentsService.GetById(ctx, offer.SchoolID, studentId)
41 | if err != nil {
42 | return primitive.ObjectID{}, err
43 | }
44 |
45 | orderAmount := s.calculateOrderPrice(offer.Price.Value, promocode)
46 |
47 | id := primitive.NewObjectID()
48 |
49 | order := domain.Order{
50 | ID: id,
51 | SchoolID: offer.SchoolID,
52 | Student: domain.StudentInfoShort{
53 | ID: student.ID,
54 | Name: student.Name,
55 | Email: student.Email,
56 | },
57 | Offer: domain.OrderOfferInfo{
58 | ID: offer.ID,
59 | Name: offer.Name,
60 | },
61 | Amount: orderAmount,
62 | Currency: offer.Price.Currency,
63 | CreatedAt: time.Now(),
64 | Status: domain.OrderStatusCreated,
65 | Transactions: make([]domain.Transaction, 0),
66 | }
67 |
68 | if !promocode.ID.IsZero() {
69 | order.Promo = domain.OrderPromoInfo{
70 | ID: promocode.ID,
71 | Code: promocode.Code,
72 | }
73 | }
74 |
75 | err = s.repo.Create(ctx, order)
76 |
77 | return id, err
78 | }
79 |
80 | func (s *OrdersService) AddTransaction(ctx context.Context, id primitive.ObjectID, transaction domain.Transaction) (domain.Order, error) {
81 | return s.repo.AddTransaction(ctx, id, transaction)
82 | }
83 |
84 | func (s *OrdersService) GetBySchool(ctx context.Context, schoolId primitive.ObjectID, query domain.GetOrdersQuery) ([]domain.Order, int64, error) {
85 | return s.repo.GetBySchool(ctx, schoolId, query)
86 | }
87 |
88 | func (s *OrdersService) GetById(ctx context.Context, id primitive.ObjectID) (domain.Order, error) {
89 | return s.repo.GetById(ctx, id)
90 | }
91 |
92 | func (s *OrdersService) SetStatus(ctx context.Context, id primitive.ObjectID, status string) error {
93 | return s.repo.SetStatus(ctx, id, status)
94 | }
95 |
96 | func (s *OrdersService) getOrderPromocode(ctx context.Context, schoolId, promocodeId primitive.ObjectID) (domain.PromoCode, error) {
97 | var (
98 | promocode domain.PromoCode
99 | err error
100 | )
101 |
102 | if !promocodeId.IsZero() {
103 | promocode, err = s.promoCodesService.GetById(ctx, schoolId, promocodeId)
104 | if err != nil {
105 | return promocode, err
106 | }
107 |
108 | if promocode.ExpiresAt.Unix() < time.Now().Unix() {
109 | return promocode, domain.ErrPromocodeExpired
110 | }
111 | }
112 |
113 | return promocode, nil
114 | }
115 |
116 | func (s *OrdersService) calculateOrderPrice(price uint, promocode domain.PromoCode) uint {
117 | if promocode.ID.IsZero() {
118 | return price
119 | } else {
120 | return (price * uint(100-promocode.DiscountPercentage)) / 100
121 | }
122 | }
123 |
--------------------------------------------------------------------------------
/internal/service/packages.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/zhashkevych/creatly-backend/internal/domain"
7 | "github.com/zhashkevych/creatly-backend/internal/repository"
8 | "go.mongodb.org/mongo-driver/bson/primitive"
9 | )
10 |
11 | type PackagesService struct {
12 | repo repository.Packages
13 | modulesRepo repository.Modules
14 | }
15 |
16 | func NewPackagesService(repo repository.Packages, modulesRepo repository.Modules) *PackagesService {
17 | return &PackagesService{repo: repo, modulesRepo: modulesRepo}
18 | }
19 |
20 | func (s *PackagesService) Create(ctx context.Context, inp CreatePackageInput) (primitive.ObjectID, error) {
21 | courseId, err := primitive.ObjectIDFromHex(inp.CourseID)
22 | if err != nil {
23 | return primitive.ObjectID{}, err
24 | }
25 |
26 | schoolId, err := primitive.ObjectIDFromHex(inp.SchoolID)
27 | if err != nil {
28 | return primitive.ObjectID{}, err
29 | }
30 |
31 | id, err := s.repo.Create(ctx, domain.Package{
32 | CourseID: courseId,
33 | SchoolID: schoolId,
34 | Name: inp.Name,
35 | })
36 |
37 | if inp.Modules != nil {
38 | moduleIds, err := stringArrayToObjectId(inp.Modules)
39 | if err != nil {
40 | return primitive.ObjectID{}, err
41 | }
42 |
43 | if err := s.modulesRepo.AttachPackage(ctx, schoolId, id, moduleIds); err != nil {
44 | return primitive.ObjectID{}, err
45 | }
46 | }
47 |
48 | return id, err
49 | }
50 |
51 | func (s *PackagesService) GetByCourse(ctx context.Context, courseID primitive.ObjectID) ([]domain.Package, error) {
52 | pkgs, err := s.repo.GetByCourse(ctx, courseID)
53 | if err != nil {
54 | return nil, err
55 | }
56 |
57 | for i := range pkgs {
58 | modules, err := s.modulesRepo.GetByPackages(ctx, []primitive.ObjectID{pkgs[i].ID})
59 | if err != nil {
60 | return nil, err
61 | }
62 |
63 | pkgs[i].Modules = modules
64 | }
65 |
66 | return pkgs, nil
67 | }
68 |
69 | func (s *PackagesService) GetById(ctx context.Context, id primitive.ObjectID) (domain.Package, error) {
70 | pkg, err := s.repo.GetById(ctx, id)
71 | if err != nil {
72 | return pkg, err
73 | }
74 |
75 | modules, err := s.modulesRepo.GetByPackages(ctx, []primitive.ObjectID{pkg.ID})
76 | if err != nil {
77 | return pkg, err
78 | }
79 |
80 | pkg.Modules = modules
81 |
82 | return pkg, nil
83 | }
84 |
85 | func (s *PackagesService) GetByIds(ctx context.Context, ids []primitive.ObjectID) ([]domain.Package, error) {
86 | if len(ids) == 0 {
87 | return nil, nil
88 | }
89 |
90 | return s.repo.GetByIds(ctx, ids)
91 | }
92 |
93 | func (s *PackagesService) Update(ctx context.Context, inp UpdatePackageInput) error {
94 | id, err := primitive.ObjectIDFromHex(inp.ID)
95 | if err != nil {
96 | return err
97 | }
98 |
99 | schoolId, err := primitive.ObjectIDFromHex(inp.SchoolID)
100 | if err != nil {
101 | return err
102 | }
103 |
104 | if inp.Name != "" {
105 | if err := s.repo.Update(ctx, repository.UpdatePackageInput{
106 | ID: id,
107 | SchoolID: schoolId,
108 | Name: inp.Name,
109 | }); err != nil {
110 | return err
111 | }
112 | }
113 |
114 | /*
115 | To update modules, that are a part of a package
116 | First we delete all modules from package and then we add new modules to the package
117 | */
118 | if inp.Modules != nil {
119 | moduleIds, err := stringArrayToObjectId(inp.Modules)
120 | if err != nil {
121 | return err
122 | }
123 |
124 | if err := s.modulesRepo.DetachPackageFromAll(ctx, schoolId, id); err != nil {
125 | return err
126 | }
127 |
128 | if err := s.modulesRepo.AttachPackage(ctx, schoolId, id, moduleIds); err != nil {
129 | return err
130 | }
131 | }
132 |
133 | return nil
134 | }
135 |
136 | func (s *PackagesService) Delete(ctx context.Context, schoolId, id primitive.ObjectID) error {
137 | return s.repo.Delete(ctx, schoolId, id)
138 | }
139 |
140 | func stringArrayToObjectId(stringIds []string) ([]primitive.ObjectID, error) {
141 | var err error
142 |
143 | ids := make([]primitive.ObjectID, len(stringIds))
144 |
145 | for i, id := range stringIds {
146 | ids[i], err = primitive.ObjectIDFromHex(id)
147 | if err != nil {
148 | return nil, err
149 | }
150 | }
151 |
152 | return ids, nil
153 | }
154 |
--------------------------------------------------------------------------------
/internal/service/payments.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 | "encoding/json"
6 | "fmt"
7 | "time"
8 |
9 | "github.com/zhashkevych/creatly-backend/internal/domain"
10 | "github.com/zhashkevych/creatly-backend/pkg/logger"
11 | "github.com/zhashkevych/creatly-backend/pkg/payment"
12 | "github.com/zhashkevych/creatly-backend/pkg/payment/fondy"
13 | "go.mongodb.org/mongo-driver/bson/primitive"
14 | )
15 |
16 | const (
17 | redirectURLTmpl = "https://%s/" // TODO: generate link with URL params for popup on frontend ?
18 | )
19 |
20 | type PaymentsService struct {
21 | ordersService Orders
22 | offersService Offers
23 | studentsService Students
24 | emailService Emails
25 | schoolsService Schools
26 |
27 | fondyCallbackURL string
28 | }
29 |
30 | func NewPaymentsService(ordersService Orders, offersService Offers, studentsService Students,
31 | emailService Emails, schoolsService Schools, fondyCallbackURL string) *PaymentsService {
32 | return &PaymentsService{
33 | ordersService: ordersService,
34 | offersService: offersService,
35 | studentsService: studentsService,
36 | emailService: emailService,
37 | schoolsService: schoolsService,
38 | fondyCallbackURL: fondyCallbackURL,
39 | }
40 | }
41 |
42 | func (s *PaymentsService) GeneratePaymentLink(ctx context.Context, orderId primitive.ObjectID) (string, error) {
43 | order, err := s.ordersService.GetById(ctx, orderId)
44 | if err != nil {
45 | return "", err
46 | }
47 |
48 | offer, err := s.offersService.GetById(ctx, order.Offer.ID)
49 | if err != nil {
50 | return "", err
51 | }
52 |
53 | if !offer.PaymentMethod.UsesProvider {
54 | return "", domain.ErrPaymentProviderNotUsed
55 | }
56 |
57 | paymentInput := payment.GeneratePaymentLinkInput{
58 | OrderId: orderId.Hex(),
59 | Amount: order.Amount,
60 | Currency: offer.Price.Currency,
61 | OrderDesc: offer.Description, // TODO proper order description
62 | }
63 |
64 | switch offer.PaymentMethod.Provider {
65 | case domain.PaymentProviderFondy:
66 | return s.generateFondyPaymentLink(ctx, offer.SchoolID, paymentInput)
67 | default:
68 | return "", domain.ErrUnknownPaymentProvider
69 | }
70 | }
71 |
72 | func (s *PaymentsService) ProcessTransaction(ctx context.Context, callback interface{}) error {
73 | switch callbackData := callback.(type) {
74 | case fondy.Callback:
75 | return s.processFondyCallback(ctx, callbackData)
76 | default:
77 | return domain.ErrUnknownCallbackType
78 | }
79 | }
80 |
81 | func (s *PaymentsService) processFondyCallback(ctx context.Context, callback fondy.Callback) error {
82 | orderID, err := primitive.ObjectIDFromHex(callback.OrderId)
83 | if err != nil {
84 | return err
85 | }
86 |
87 | order, err := s.ordersService.GetById(ctx, orderID)
88 | if err != nil {
89 | return err
90 | }
91 |
92 | school, err := s.schoolsService.GetById(ctx, order.SchoolID)
93 | if err != nil {
94 | return err
95 | }
96 |
97 | client, err := s.getFondyClient(school.Settings.Fondy)
98 | if err != nil {
99 | return err
100 | }
101 |
102 | if err := client.ValidateCallback(callback); err != nil {
103 | return domain.ErrTransactionInvalid
104 | }
105 |
106 | transaction, err := createTransaction(callback)
107 | if err != nil {
108 | return err
109 | }
110 |
111 | order, err = s.ordersService.AddTransaction(ctx, orderID, transaction)
112 | if err != nil {
113 | return err
114 | }
115 |
116 | if transaction.Status != domain.OrderStatusPaid {
117 | return nil
118 | }
119 |
120 | offer, err := s.offersService.GetById(ctx, order.Offer.ID)
121 | if err != nil {
122 | return err
123 | }
124 |
125 | if err := s.emailService.SendStudentPurchaseSuccessfulEmail(StudentPurchaseSuccessfulEmailInput{
126 | Name: order.Student.Name,
127 | Email: order.Student.Email,
128 | CourseName: order.Offer.Name,
129 | }); err != nil {
130 | logger.Errorf("failed to send email after purchase: %s", err.Error())
131 | }
132 |
133 | return s.studentsService.GiveAccessToOffer(ctx, order.Student.ID, offer)
134 | }
135 |
136 | func (s *PaymentsService) generateFondyPaymentLink(ctx context.Context, schoolId primitive.ObjectID,
137 | input payment.GeneratePaymentLinkInput) (string, error) {
138 | school, err := s.schoolsService.GetById(ctx, schoolId)
139 | if err != nil {
140 | return "", err
141 | }
142 |
143 | client, err := s.getFondyClient(school.Settings.Fondy)
144 | if err != nil {
145 | return "", err
146 | }
147 |
148 | input.CallbackURL = s.fondyCallbackURL
149 | input.RedirectURL = getRedirectURL(school.Settings.GetDomain())
150 |
151 | logger.Infof("%+v", input)
152 |
153 | return client.GeneratePaymentLink(input)
154 | }
155 |
156 | func createTransaction(callbackData fondy.Callback) (domain.Transaction, error) {
157 | var status string
158 | if callbackData.PaymentApproved() {
159 | status = domain.OrderStatusPaid
160 | } else {
161 | status = domain.OrderStatusOther
162 | }
163 |
164 | if !callbackData.Success() {
165 | status = domain.OrderStatusFailed
166 | }
167 |
168 | additionalInfo, err := json.Marshal(callbackData)
169 | if err != nil {
170 | return domain.Transaction{}, err
171 | }
172 |
173 | return domain.Transaction{
174 | Status: status,
175 | CreatedAt: time.Now(),
176 | AdditionalInfo: string(additionalInfo),
177 | }, nil
178 | }
179 |
180 | func (s *PaymentsService) getFondyClient(fondyConnectionInfo domain.Fondy) (*fondy.Client, error) {
181 | if !fondyConnectionInfo.Connected {
182 | return nil, domain.ErrFondyIsNotConnected
183 | }
184 |
185 | return fondy.NewFondyClient(fondyConnectionInfo.MerchantID, fondyConnectionInfo.MerchantPassword), nil
186 | }
187 |
188 | func getRedirectURL(domain string) string {
189 | return fmt.Sprintf(redirectURLTmpl, domain)
190 | }
191 |
--------------------------------------------------------------------------------
/internal/service/promocodes.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 | "errors"
6 |
7 | "github.com/zhashkevych/creatly-backend/internal/domain"
8 | "github.com/zhashkevych/creatly-backend/internal/repository"
9 | "go.mongodb.org/mongo-driver/bson/primitive"
10 | )
11 |
12 | type PromoCodeService struct {
13 | repo repository.PromoCodes
14 | }
15 |
16 | func NewPromoCodeService(repo repository.PromoCodes) *PromoCodeService {
17 | return &PromoCodeService{repo: repo}
18 | }
19 |
20 | func (s *PromoCodeService) Create(ctx context.Context, inp CreatePromoCodeInput) (primitive.ObjectID, error) {
21 | return s.repo.Create(ctx, domain.PromoCode{
22 | SchoolID: inp.SchoolID,
23 | Code: inp.Code,
24 | DiscountPercentage: inp.DiscountPercentage,
25 | ExpiresAt: inp.ExpiresAt,
26 | OfferIDs: inp.OfferIDs,
27 | })
28 | }
29 |
30 | func (s *PromoCodeService) Update(ctx context.Context, inp domain.UpdatePromoCodeInput) error {
31 | return s.repo.Update(ctx, inp)
32 | }
33 |
34 | func (s *PromoCodeService) Delete(ctx context.Context, schoolId, id primitive.ObjectID) error {
35 | return s.repo.Delete(ctx, schoolId, id)
36 | }
37 |
38 | func (s *PromoCodeService) GetByCode(ctx context.Context, schoolId primitive.ObjectID, code string) (domain.PromoCode, error) {
39 | promo, err := s.repo.GetByCode(ctx, schoolId, code)
40 | if err != nil {
41 | if errors.Is(err, domain.ErrPromoNotFound) {
42 | return domain.PromoCode{}, err
43 | }
44 |
45 | return domain.PromoCode{}, err
46 | }
47 |
48 | return promo, nil
49 | }
50 |
51 | func (s *PromoCodeService) GetById(ctx context.Context, schoolId, id primitive.ObjectID) (domain.PromoCode, error) {
52 | promo, err := s.repo.GetById(ctx, schoolId, id)
53 | if err != nil {
54 | if errors.Is(err, domain.ErrPromoNotFound) {
55 | return domain.PromoCode{}, err
56 | }
57 |
58 | return domain.PromoCode{}, err
59 | }
60 |
61 | return promo, nil
62 | }
63 |
64 | func (s *PromoCodeService) GetBySchool(ctx context.Context, schoolId primitive.ObjectID) ([]domain.PromoCode, error) {
65 | return s.repo.GetBySchool(ctx, schoolId)
66 | }
67 |
--------------------------------------------------------------------------------
/internal/service/schools.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/zhashkevych/creatly-backend/pkg/payment"
7 | "github.com/zhashkevych/creatly-backend/pkg/payment/fondy"
8 |
9 | "go.mongodb.org/mongo-driver/bson/primitive"
10 |
11 | "github.com/zhashkevych/creatly-backend/internal/domain"
12 | "github.com/zhashkevych/creatly-backend/internal/repository"
13 | "github.com/zhashkevych/creatly-backend/pkg/cache"
14 | )
15 |
16 | type SchoolsService struct {
17 | repo repository.Schools
18 | cache cache.Cache
19 | ttl int64
20 | }
21 |
22 | func NewSchoolsService(repo repository.Schools, cache cache.Cache, ttl int64) *SchoolsService {
23 | return &SchoolsService{repo: repo, cache: cache, ttl: ttl}
24 | }
25 |
26 | func (s *SchoolsService) Create(ctx context.Context, name string) (primitive.ObjectID, error) {
27 | return s.repo.Create(ctx, name)
28 | }
29 |
30 | func (s *SchoolsService) GetByDomain(ctx context.Context, domainName string) (domain.School, error) {
31 | if value, err := s.cache.Get(domainName); err == nil {
32 | return value.(domain.School), nil
33 | }
34 |
35 | school, err := s.repo.GetByDomain(ctx, domainName)
36 | if err != nil {
37 | return domain.School{}, err
38 | }
39 |
40 | err = s.cache.Set(domainName, school, s.ttl)
41 |
42 | return school, err
43 | }
44 |
45 | func (s *SchoolsService) GetById(ctx context.Context, id primitive.ObjectID) (domain.School, error) {
46 | return s.repo.GetById(ctx, id)
47 | }
48 |
49 | func (s *SchoolsService) UpdateSettings(ctx context.Context, schoolId primitive.ObjectID, inp domain.UpdateSchoolSettingsInput) error {
50 | return s.repo.UpdateSettings(ctx, schoolId, inp)
51 | }
52 |
53 | func (s *SchoolsService) ConnectFondy(ctx context.Context, input ConnectFondyInput) error {
54 | client := fondy.NewFondyClient(input.MerchantID, input.MerchantPassword)
55 |
56 | id := primitive.NewObjectID()
57 |
58 | _, err := client.GeneratePaymentLink(payment.GeneratePaymentLinkInput{
59 | OrderId: id.Hex(),
60 | Amount: 1000,
61 | Currency: "USD",
62 | OrderDesc: "CREATLY - TESTING FONDY CREDENTIALS",
63 | })
64 | if err != nil {
65 | return err
66 | }
67 |
68 | creds := domain.Fondy{
69 | MerchantPassword: input.MerchantPassword,
70 | MerchantID: input.MerchantID,
71 | Connected: true,
72 | }
73 |
74 | return s.repo.SetFondyCredentials(ctx, input.SchoolID, creds)
75 | }
76 |
77 | func (s *SchoolsService) ConnectSendPulse(ctx context.Context, input ConnectSendPulseInput) error {
78 | // todo
79 | return nil
80 | }
81 |
--------------------------------------------------------------------------------
/internal/service/student_lessons.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/zhashkevych/creatly-backend/internal/repository"
7 | "go.mongodb.org/mongo-driver/bson/primitive"
8 | )
9 |
10 | type StudentLessonsService struct {
11 | repo repository.StudentLessons
12 | }
13 |
14 | func NewStudentLessonsService(repo repository.StudentLessons) *StudentLessonsService {
15 | return &StudentLessonsService{
16 | repo: repo,
17 | }
18 | }
19 |
20 | func (s *StudentLessonsService) AddFinished(ctx context.Context, studentID, lessonID primitive.ObjectID) error {
21 | return s.repo.AddFinished(ctx, studentID, lessonID)
22 | }
23 |
24 | func (s *StudentLessonsService) SetLastOpened(ctx context.Context, studentID, lessonID primitive.ObjectID) error {
25 | return s.repo.SetLastOpened(ctx, studentID, lessonID)
26 | }
27 |
--------------------------------------------------------------------------------
/internal/service/surveys.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/zhashkevych/creatly-backend/internal/domain"
8 | "github.com/zhashkevych/creatly-backend/internal/repository"
9 | "go.mongodb.org/mongo-driver/bson/primitive"
10 | )
11 |
12 | type SurveysService struct {
13 | modulesRepo repository.Modules
14 | surveyResultsRepo repository.SurveyResults
15 | studentsRepo repository.Students
16 | }
17 |
18 | func NewSurveysService(modulesRepo repository.Modules, surveyResultsRepo repository.SurveyResults, studentsRepo repository.Students) *SurveysService {
19 | return &SurveysService{modulesRepo: modulesRepo, surveyResultsRepo: surveyResultsRepo, studentsRepo: studentsRepo}
20 | }
21 |
22 | func (s *SurveysService) Create(ctx context.Context, inp CreateSurveyInput) error {
23 | for i := range inp.Survey.Questions {
24 | inp.Survey.Questions[i].ID = primitive.NewObjectID()
25 | }
26 |
27 | return s.modulesRepo.AttachSurvey(ctx, inp.SchoolID, inp.ModuleID, inp.Survey)
28 | }
29 |
30 | func (s *SurveysService) Delete(ctx context.Context, schoolId, moduleId primitive.ObjectID) error {
31 | return s.modulesRepo.DetachSurvey(ctx, schoolId, moduleId)
32 | }
33 |
34 | func (s *SurveysService) SaveStudentAnswers(ctx context.Context, inp SaveStudentAnswersInput) error {
35 | student, err := s.studentsRepo.GetById(ctx, inp.SchoolID, inp.StudentID)
36 | if err != nil {
37 | return err
38 | }
39 |
40 | return s.surveyResultsRepo.Save(ctx, domain.SurveyResult{
41 | Student: domain.StudentInfoShort{
42 | ID: student.ID,
43 | Name: student.Name,
44 | Email: student.Email,
45 | },
46 | ModuleID: inp.ModuleID,
47 | SubmittedAt: time.Now(),
48 | Answers: inp.Answers,
49 | })
50 | }
51 |
52 | func (s *SurveysService) GetResultsByModule(ctx context.Context, moduleId primitive.ObjectID,
53 | pagination *domain.PaginationQuery) ([]domain.SurveyResult, int64, error) {
54 | return s.surveyResultsRepo.GetAllByModule(ctx, moduleId, pagination)
55 | }
56 |
57 | func (s *SurveysService) GetStudentResults(ctx context.Context, moduleID, studentID primitive.ObjectID) (domain.SurveyResult, error) {
58 | return s.surveyResultsRepo.GetByStudent(ctx, moduleID, studentID)
59 | }
60 |
--------------------------------------------------------------------------------
/pkg/auth/manager.go:
--------------------------------------------------------------------------------
1 | package auth
2 |
3 | import (
4 | "errors"
5 | "fmt"
6 | "math/rand"
7 | "time"
8 |
9 | "github.com/dgrijalva/jwt-go"
10 | )
11 |
12 | // TokenManager provides logic for JWT & Refresh tokens generation and parsing.
13 | type TokenManager interface {
14 | NewJWT(userId string, ttl time.Duration) (string, error)
15 | Parse(accessToken string) (string, error)
16 | NewRefreshToken() (string, error)
17 | }
18 |
19 | type Manager struct {
20 | signingKey string
21 | }
22 |
23 | func NewManager(signingKey string) (*Manager, error) {
24 | if signingKey == "" {
25 | return nil, errors.New("empty signing key")
26 | }
27 |
28 | return &Manager{signingKey: signingKey}, nil
29 | }
30 |
31 | func (m *Manager) NewJWT(userId string, ttl time.Duration) (string, error) {
32 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.StandardClaims{
33 | ExpiresAt: time.Now().Add(ttl).Unix(),
34 | Subject: userId,
35 | })
36 |
37 | return token.SignedString([]byte(m.signingKey))
38 | }
39 |
40 | func (m *Manager) Parse(accessToken string) (string, error) {
41 | token, err := jwt.Parse(accessToken, func(token *jwt.Token) (i interface{}, err error) {
42 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
43 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
44 | }
45 |
46 | return []byte(m.signingKey), nil
47 | })
48 | if err != nil {
49 | return "", err
50 | }
51 |
52 | claims, ok := token.Claims.(jwt.MapClaims)
53 | if !ok {
54 | return "", fmt.Errorf("error get user claims from token")
55 | }
56 |
57 | return claims["sub"].(string), nil
58 | }
59 |
60 | func (m *Manager) NewRefreshToken() (string, error) {
61 | b := make([]byte, 32)
62 |
63 | s := rand.NewSource(time.Now().Unix())
64 | r := rand.New(s)
65 |
66 | if _, err := r.Read(b); err != nil {
67 | return "", err
68 | }
69 |
70 | return fmt.Sprintf("%x", b), nil
71 | }
72 |
--------------------------------------------------------------------------------
/pkg/cache/cache.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | type Cache interface {
4 | Set(key, value interface{}, ttl int64) error
5 | Get(key interface{}) (interface{}, error)
6 | }
7 |
--------------------------------------------------------------------------------
/pkg/cache/memory.go:
--------------------------------------------------------------------------------
1 | package cache
2 |
3 | import (
4 | "errors"
5 | "sync"
6 | "time"
7 | )
8 |
9 | var ErrItemNotFound = errors.New("cache: item not found")
10 |
11 | type item struct {
12 | value interface{}
13 | createdAt int64
14 | ttl int64
15 | }
16 |
17 | type MemoryCache struct {
18 | cache map[interface{}]*item
19 | sync.RWMutex
20 | }
21 |
22 | // NewMemoryCache uses map to store key:value data in-memory.
23 | func NewMemoryCache() *MemoryCache {
24 | c := &MemoryCache{cache: make(map[interface{}]*item)}
25 | go c.setTtlTimer()
26 |
27 | return c
28 | }
29 |
30 | func (c *MemoryCache) setTtlTimer() {
31 | for {
32 | c.Lock()
33 | for k, v := range c.cache {
34 | if time.Now().Unix()-v.createdAt > v.ttl {
35 | delete(c.cache, k)
36 | }
37 | }
38 | c.Unlock()
39 |
40 | <-time.After(time.Second)
41 | }
42 | }
43 |
44 | func (c *MemoryCache) Set(key, value interface{}, ttl int64) error {
45 | c.Lock()
46 | c.cache[key] = &item{
47 | value: value,
48 | createdAt: time.Now().Unix(),
49 | ttl: ttl,
50 | }
51 | c.Unlock()
52 |
53 | return nil
54 | }
55 |
56 | func (c *MemoryCache) Get(key interface{}) (interface{}, error) {
57 | c.RLock()
58 | item, ex := c.cache[key]
59 | c.RUnlock()
60 |
61 | if !ex {
62 | return nil, ErrItemNotFound
63 | }
64 |
65 | return item.value, nil
66 | }
67 |
--------------------------------------------------------------------------------
/pkg/database/mongodb/mongodb.go:
--------------------------------------------------------------------------------
1 | package mongodb
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "time"
7 |
8 | "go.mongodb.org/mongo-driver/mongo"
9 | "go.mongodb.org/mongo-driver/mongo/options"
10 | )
11 |
12 | const timeout = 10 * time.Second
13 |
14 | // NewClient established connection to a mongoDb instance using provided URI and auth credentials.
15 | func NewClient(uri, username, password string) (*mongo.Client, error) {
16 | opts := options.Client().ApplyURI(uri)
17 | if username != "" && password != "" {
18 | opts.SetAuth(options.Credential{
19 | Username: username, Password: password,
20 | })
21 | }
22 |
23 | client, err := mongo.NewClient(opts)
24 | if err != nil {
25 | return nil, err
26 | }
27 |
28 | ctx, cancel := context.WithTimeout(context.Background(), timeout)
29 | defer cancel()
30 |
31 | err = client.Connect(ctx)
32 | if err != nil {
33 | return nil, err
34 | }
35 |
36 | err = client.Ping(context.Background(), nil)
37 | if err != nil {
38 | return nil, err
39 | }
40 |
41 | return client, nil
42 | }
43 |
44 | func IsDuplicate(err error) bool {
45 | var e mongo.WriteException
46 | if errors.As(err, &e) {
47 | for _, we := range e.WriteErrors {
48 | if we.Code == 11000 {
49 | return true
50 | }
51 | }
52 | }
53 |
54 | return false
55 | }
56 |
--------------------------------------------------------------------------------
/pkg/dns/dns.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/cloudflare/cloudflare-go"
7 | )
8 |
9 | type DomainManager interface {
10 | AddCNAMERecord(ctx context.Context, subdomain string) error
11 | }
12 |
13 | type Service struct {
14 | client *cloudflare.API
15 | email string
16 | cnameTarget string
17 | }
18 |
19 | func NewService(client *cloudflare.API, email, cnameTarget string) *Service {
20 | return &Service{
21 | client: client,
22 | email: email,
23 | cnameTarget: cnameTarget,
24 | }
25 | }
26 |
27 | func (s *Service) AddCNAMERecord(ctx context.Context, subdomain string) error {
28 | id, err := s.client.ZoneIDByName(s.email)
29 | if err != nil {
30 | return err
31 | }
32 |
33 | // todo enable proxy
34 | proxied := true
35 | _, err = s.client.CreateDNSRecord(ctx, id, cloudflare.DNSRecord{
36 | Name: subdomain,
37 | Type: "CNAME",
38 | Content: s.cnameTarget,
39 | TTL: 1,
40 | Proxied: &proxied,
41 | })
42 |
43 | return err
44 | }
45 |
--------------------------------------------------------------------------------
/pkg/email/mock/mock.go:
--------------------------------------------------------------------------------
1 | package mock_email
2 |
3 | import (
4 | "github.com/stretchr/testify/mock"
5 | "github.com/zhashkevych/creatly-backend/pkg/email"
6 | )
7 |
8 | type EmailProvider struct {
9 | mock.Mock
10 | }
11 |
12 | func (m *EmailProvider) AddEmailToList(inp email.AddEmailInput) error {
13 | args := m.Called(inp)
14 |
15 | return args.Error(0)
16 | }
17 |
18 | type EmailSender struct {
19 | mock.Mock
20 | }
21 |
22 | func (m *EmailSender) Send(inp email.SendEmailInput) error {
23 | args := m.Called(inp)
24 |
25 | return args.Error(0)
26 | }
27 |
--------------------------------------------------------------------------------
/pkg/email/provider.go:
--------------------------------------------------------------------------------
1 | package email
2 |
3 | type AddEmailInput struct {
4 | Email string
5 | ListID string
6 | Variables map[string]string
7 | }
8 |
9 | type Provider interface {
10 | AddEmailToList(AddEmailInput) error
11 | }
12 |
--------------------------------------------------------------------------------
/pkg/email/sender.go:
--------------------------------------------------------------------------------
1 | package email
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "html/template"
7 |
8 | "github.com/zhashkevych/creatly-backend/pkg/logger"
9 | )
10 |
11 | type SendEmailInput struct {
12 | To string
13 | Subject string
14 | Body string
15 | }
16 |
17 | type Sender interface {
18 | Send(input SendEmailInput) error
19 | }
20 |
21 | func (e *SendEmailInput) GenerateBodyFromHTML(templateFileName string, data interface{}) error {
22 | t, err := template.ParseFiles(templateFileName)
23 | if err != nil {
24 | logger.Errorf("failed to parse file %s:%s", templateFileName, err.Error())
25 |
26 | return err
27 | }
28 |
29 | buf := new(bytes.Buffer)
30 | if err = t.Execute(buf, data); err != nil {
31 | return err
32 | }
33 |
34 | e.Body = buf.String()
35 |
36 | return nil
37 | }
38 |
39 | func (e *SendEmailInput) Validate() error {
40 | if e.To == "" {
41 | return errors.New("empty to")
42 | }
43 |
44 | if e.Subject == "" || e.Body == "" {
45 | return errors.New("empty subject/body")
46 | }
47 |
48 | if !IsEmailValid(e.To) {
49 | return errors.New("invalid to email")
50 | }
51 |
52 | return nil
53 | }
54 |
--------------------------------------------------------------------------------
/pkg/email/sendpulse/client.go:
--------------------------------------------------------------------------------
1 | package sendpulse
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "errors"
7 | "fmt"
8 | "io/ioutil"
9 | "net/http"
10 |
11 | "github.com/zhashkevych/creatly-backend/pkg/cache"
12 | "github.com/zhashkevych/creatly-backend/pkg/email"
13 | "github.com/zhashkevych/creatly-backend/pkg/logger"
14 | )
15 |
16 | // Documentation https://sendpulse.com/integrations/api
17 | // Note: The request limit is 10 requests per second.
18 |
19 | const (
20 | endpoint = "https://api.sendpulse.com"
21 | authorizeEndpoint = "/oauth/access_token"
22 | addToListEndpoint = "/addressbooks/%s/emails" // addressbooks/{id}/emails
23 |
24 | grantType = "client_credentials"
25 |
26 | cacheTTL = 3600 // In seconds. SendPulse access tokens are valid for 1 hour
27 | )
28 |
29 | type authRequest struct {
30 | GrantType string `json:"grant_type"`
31 | ClientID string `json:"client_id"`
32 | ClientSecret string `json:"client_secret"`
33 | }
34 |
35 | type authResponse struct {
36 | AccessToken string `json:"access_token"`
37 | TokenType string `json:"token_type"`
38 | ExpiresIn int `json:"expires_in"`
39 | }
40 |
41 | type addToListRequest struct {
42 | Emails []emailInfo `json:"emails"`
43 | }
44 |
45 | type emailInfo struct {
46 | Email string `json:"email"`
47 | Variables map[string]string `json:"variables"`
48 | }
49 |
50 | // Client is SendPulse API client implementation.
51 | type Client struct {
52 | id string
53 | secret string
54 |
55 | cache cache.Cache
56 | }
57 |
58 | func NewClient(id, secret string, cache cache.Cache) *Client {
59 | return &Client{id: id, secret: secret, cache: cache}
60 | }
61 |
62 | // AddEmailToList adds lead to provided email list with specific variables.
63 | func (c *Client) AddEmailToList(input email.AddEmailInput) error {
64 | token, err := c.getToken()
65 | if err != nil {
66 | return err
67 | }
68 |
69 | reqData := addToListRequest{
70 | Emails: []emailInfo{
71 | {
72 | Email: input.Email,
73 | Variables: input.Variables,
74 | },
75 | },
76 | }
77 |
78 | reqBody, err := json.Marshal(reqData)
79 | if err != nil {
80 | return err
81 | }
82 |
83 | path := fmt.Sprintf(addToListEndpoint, input.ListID)
84 |
85 | req, err := http.NewRequest(http.MethodPost, endpoint+path, bytes.NewBuffer(reqBody))
86 | if err != nil {
87 | return err
88 | }
89 |
90 | req.Header.Set("Content-Type", "application/json")
91 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
92 |
93 | resp, err := http.DefaultClient.Do(req)
94 | if err != nil {
95 | return err
96 | }
97 |
98 | defer resp.Body.Close()
99 |
100 | body, err := ioutil.ReadAll(resp.Body)
101 | if err != nil {
102 | return err
103 | }
104 |
105 | logger.Infof("SendPulse response: %s", string(body))
106 |
107 | if resp.StatusCode != 200 {
108 | return errors.New("status code is not OK")
109 | }
110 |
111 | return nil
112 | }
113 |
114 | func (c *Client) getToken() (string, error) {
115 | // todo set unique key (by schoolId)
116 | token, err := c.cache.Get("t")
117 | if err == nil {
118 | return token.(string), nil
119 | }
120 |
121 | token, err = c.authenticate()
122 | if err != nil {
123 | return "", err
124 | }
125 |
126 | err = c.cache.Set("t", token, cacheTTL)
127 |
128 | return token.(string), err
129 | }
130 |
131 | func (c *Client) authenticate() (string, error) {
132 | reqData := authRequest{
133 | GrantType: grantType,
134 | ClientID: c.id,
135 | ClientSecret: c.secret,
136 | }
137 |
138 | reqBody, err := json.Marshal(reqData)
139 | if err != nil {
140 | return "", err
141 | }
142 |
143 | resp, err := http.Post(endpoint+authorizeEndpoint, "application/json", bytes.NewBuffer(reqBody))
144 | if err != nil {
145 | return "", err
146 | }
147 | defer resp.Body.Close()
148 |
149 | if resp.StatusCode != 200 {
150 | return "", errors.New("status code is not OK")
151 | }
152 |
153 | var respData authResponse
154 |
155 | respBody, err := ioutil.ReadAll(resp.Body)
156 | if err != nil {
157 | return "", err
158 | }
159 |
160 | err = json.Unmarshal(respBody, &respData)
161 | if err != nil {
162 | return "", err
163 | }
164 |
165 | return respData.AccessToken, nil
166 | }
167 |
--------------------------------------------------------------------------------
/pkg/email/smtp/smtp.go:
--------------------------------------------------------------------------------
1 | package smtp
2 |
3 | import (
4 | "github.com/go-gomail/gomail"
5 | "github.com/pkg/errors"
6 | "github.com/zhashkevych/creatly-backend/pkg/email"
7 | )
8 |
9 | type SMTPSender struct {
10 | from string
11 | pass string
12 | host string
13 | port int
14 | }
15 |
16 | func NewSMTPSender(from, pass, host string, port int) (*SMTPSender, error) {
17 | if !email.IsEmailValid(from) {
18 | return nil, errors.New("invalid from email")
19 | }
20 |
21 | return &SMTPSender{from: from, pass: pass, host: host, port: port}, nil
22 | }
23 |
24 | func (s *SMTPSender) Send(input email.SendEmailInput) error {
25 | if err := input.Validate(); err != nil {
26 | return err
27 | }
28 |
29 | msg := gomail.NewMessage()
30 | msg.SetHeader("From", s.from)
31 | msg.SetHeader("To", input.To)
32 | msg.SetHeader("Subject", input.Subject)
33 | msg.SetBody("text/html", input.Body)
34 |
35 | dialer := gomail.NewDialer(s.host, s.port, s.from, s.pass)
36 | if err := dialer.DialAndSend(msg); err != nil {
37 | return errors.Wrap(err, "failed to sent email via smtp")
38 | }
39 |
40 | return nil
41 | }
42 |
--------------------------------------------------------------------------------
/pkg/email/validate.go:
--------------------------------------------------------------------------------
1 | package email
2 |
3 | import "regexp"
4 |
5 | const (
6 | minEmailLen = 3
7 | maxEmailLen = 255
8 | )
9 |
10 | var emailRegex = regexp.MustCompile("^[a-zA-Z0-9.!#$%&'*+\\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$")
11 |
12 | func IsEmailValid(email string) bool {
13 | if len(email) < minEmailLen || len(email) > maxEmailLen {
14 | return false
15 | }
16 |
17 | return emailRegex.MatchString(email)
18 | }
19 |
--------------------------------------------------------------------------------
/pkg/hash/password.go:
--------------------------------------------------------------------------------
1 | package hash
2 |
3 | import (
4 | "crypto/sha1"
5 | "fmt"
6 | )
7 |
8 | // PasswordHasher provides hashing logic to securely store passwords.
9 | type PasswordHasher interface {
10 | Hash(password string) (string, error)
11 | }
12 |
13 | // SHA1Hasher uses SHA1 to hash passwords with provided salt.
14 | type SHA1Hasher struct {
15 | salt string
16 | }
17 |
18 | func NewSHA1Hasher(salt string) *SHA1Hasher {
19 | return &SHA1Hasher{salt: salt}
20 | }
21 |
22 | // Hash creates SHA1 hash of given password.
23 | func (h *SHA1Hasher) Hash(password string) (string, error) {
24 | hash := sha1.New()
25 |
26 | if _, err := hash.Write([]byte(password)); err != nil {
27 | return "", err
28 | }
29 |
30 | return fmt.Sprintf("%x", hash.Sum([]byte(h.salt))), nil
31 | }
32 |
--------------------------------------------------------------------------------
/pkg/limiter/limiter.go:
--------------------------------------------------------------------------------
1 | package limiter
2 |
3 | import (
4 | "net"
5 | "net/http"
6 | "sync"
7 | "time"
8 |
9 | "github.com/gin-gonic/gin"
10 | "github.com/zhashkevych/creatly-backend/pkg/logger"
11 | "golang.org/x/time/rate"
12 | )
13 |
14 | // visitor holds limiter and lastSeen for specific user.
15 | type visitor struct {
16 | limiter *rate.Limiter
17 | lastSeen time.Time
18 | }
19 |
20 | // rateLimiter used to rate limit an incoming requests.
21 | type rateLimiter struct {
22 | sync.RWMutex
23 |
24 | visitors map[string]*visitor
25 | limit rate.Limit
26 | burst int
27 | ttl time.Duration
28 | }
29 |
30 | // newRateLimiter creates an instance of the rateLimiter.
31 | func newRateLimiter(rps, burst int, ttl time.Duration) *rateLimiter {
32 | return &rateLimiter{
33 | visitors: make(map[string]*visitor),
34 | limit: rate.Limit(rps),
35 | burst: burst,
36 | ttl: ttl,
37 | }
38 | }
39 |
40 | // getVisitor returns limiter for the specific visitor by its IP,
41 | // looking up within the visitors map.
42 | func (l *rateLimiter) getVisitor(ip string) *rate.Limiter {
43 | l.RLock()
44 | v, exists := l.visitors[ip]
45 | l.RUnlock()
46 |
47 | if !exists {
48 | limiter := rate.NewLimiter(l.limit, l.burst)
49 | l.Lock()
50 | l.visitors[ip] = &visitor{limiter, time.Now()}
51 | l.Unlock()
52 |
53 | return limiter
54 | }
55 |
56 | v.lastSeen = time.Now()
57 |
58 | return v.limiter
59 | }
60 |
61 | // cleanupVisitors removes old entries from the visitors map.
62 | func (l *rateLimiter) cleanupVisitors() {
63 | for {
64 | time.Sleep(time.Minute)
65 |
66 | l.Lock()
67 | for ip, v := range l.visitors {
68 | if time.Since(v.lastSeen) > l.ttl {
69 | delete(l.visitors, ip)
70 | }
71 | }
72 | l.Unlock()
73 | }
74 | }
75 |
76 | // Limit creates a new rate limiter middleware handler.
77 | func Limit(rps int, burst int, ttl time.Duration) gin.HandlerFunc {
78 | l := newRateLimiter(rps, burst, ttl)
79 |
80 | // run a background worker to clean up old entries
81 | go l.cleanupVisitors()
82 |
83 | return func(c *gin.Context) {
84 | ip, _, err := net.SplitHostPort(c.Request.RemoteAddr)
85 | if err != nil {
86 | logger.Error(err)
87 | c.AbortWithStatus(http.StatusInternalServerError)
88 |
89 | return
90 | }
91 |
92 | if !l.getVisitor(ip).Allow() {
93 | c.AbortWithStatus(http.StatusTooManyRequests)
94 |
95 | return
96 | }
97 |
98 | c.Next()
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/pkg/logger/logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | type Logger interface {
4 | Debug(msg string, params map[string]interface{})
5 | Info(msg string, params map[string]interface{})
6 | Warn(msg string, params map[string]interface{})
7 | Error(msg string, params map[string]interface{})
8 | }
9 |
--------------------------------------------------------------------------------
/pkg/logger/logrus.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "github.com/sirupsen/logrus"
5 | )
6 |
7 | // TODO create general interface with generic fields
8 |
9 | func Debug(msg ...interface{}) {
10 | logrus.Debug(msg...)
11 | }
12 |
13 | func Debugf(format string, args ...interface{}) {
14 | logrus.Debugf(format, args...)
15 | }
16 |
17 | func Info(msg ...interface{}) {
18 | logrus.Info(msg...)
19 | }
20 |
21 | func Infof(format string, args ...interface{}) {
22 | logrus.Infof(format, args...)
23 | }
24 |
25 | func Warn(msg ...interface{}) {
26 | logrus.Warn(msg...)
27 | }
28 |
29 | func Warnf(format string, args ...interface{}) {
30 | logrus.Warnf(format, args...)
31 | }
32 |
33 | func Error(msg ...interface{}) {
34 | logrus.Error(msg...)
35 | }
36 |
37 | func Errorf(format string, args ...interface{}) {
38 | logrus.Errorf(format, args...)
39 | }
40 |
--------------------------------------------------------------------------------
/pkg/otp/mock.go:
--------------------------------------------------------------------------------
1 | package otp
2 |
3 | import "github.com/stretchr/testify/mock"
4 |
5 | type MockGenerator struct {
6 | mock.Mock
7 | }
8 |
9 | func (m *MockGenerator) RandomSecret(length int) string {
10 | args := m.Called(length)
11 |
12 | return args.Get(0).(string)
13 | }
14 |
--------------------------------------------------------------------------------
/pkg/otp/otp.go:
--------------------------------------------------------------------------------
1 | package otp
2 |
3 | import "github.com/xlzd/gotp"
4 |
5 | type Generator interface {
6 | RandomSecret(length int) string
7 | }
8 |
9 | type GOTPGenerator struct{}
10 |
11 | func NewGOTPGenerator() *GOTPGenerator {
12 | return &GOTPGenerator{}
13 | }
14 |
15 | func (g *GOTPGenerator) RandomSecret(length int) string {
16 | return gotp.RandomSecret(length)
17 | }
18 |
--------------------------------------------------------------------------------
/pkg/payment/provider.go:
--------------------------------------------------------------------------------
1 | package payment
2 |
3 | type GeneratePaymentLinkInput struct {
4 | OrderId string
5 | Amount uint
6 | Currency string
7 | OrderDesc string
8 | CallbackURL string
9 | RedirectURL string
10 | }
11 |
12 | type Provider interface {
13 | GeneratePaymentLink(input GeneratePaymentLinkInput) (string, error)
14 | ValidateCallback(input interface{}) error
15 | }
16 |
--------------------------------------------------------------------------------
/pkg/storage/minio.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "context"
5 | "fmt"
6 |
7 | "github.com/minio/minio-go/v7"
8 | )
9 |
10 | type FileStorage struct {
11 | client *minio.Client
12 | bucket string
13 | endpoint string
14 | }
15 |
16 | func NewFileStorage(client *minio.Client, bucket, endpoint string) *FileStorage {
17 | return &FileStorage{
18 | client: client,
19 | bucket: bucket,
20 | endpoint: endpoint,
21 | }
22 | }
23 |
24 | func (fs *FileStorage) Upload(ctx context.Context, input UploadInput) (string, error) {
25 | opts := minio.PutObjectOptions{
26 | ContentType: input.ContentType,
27 | UserMetadata: map[string]string{"x-amz-acl": "public-read"},
28 | }
29 |
30 | _, err := fs.client.PutObject(ctx, fs.bucket, input.Name, input.File, input.Size, opts)
31 | if err != nil {
32 | return "", err
33 | }
34 |
35 | return fs.generateFileURL(input.Name), nil
36 | }
37 |
38 | // DigitalOcean Spaces URL format.
39 | func (fs *FileStorage) generateFileURL(filename string) string {
40 | return fmt.Sprintf("https://%s.%s/%s", fs.bucket, fs.endpoint, filename)
41 | }
42 |
--------------------------------------------------------------------------------
/pkg/storage/storage.go:
--------------------------------------------------------------------------------
1 | package storage
2 |
3 | import (
4 | "context"
5 | "io"
6 | )
7 |
8 | type UploadInput struct {
9 | File io.Reader
10 | Name string
11 | Size int64
12 | ContentType string
13 | }
14 |
15 | type Provider interface {
16 | Upload(ctx context.Context, input UploadInput) (string, error)
17 | }
18 |
--------------------------------------------------------------------------------
/templates/purchase_successful.html:
--------------------------------------------------------------------------------
1 | {{.Name}}, спасибо большое за покупку "{{.CourseName}}"!
2 |
3 | Надеюсь данный материал будет тебе полезен и интересен!
4 | Если у тебя возникают вопросы или ты хочешь поделиться своим отзывом - пиши мне письмо на zhashkevychmaksim@gmail.com.
5 | Мне крайне важен твой отзыв, чтобы улучшать материалы и делать курс максимально полезным!
6 |
7 |
8 |
9 | С уважением, Максим
--------------------------------------------------------------------------------
/templates/verification_email.html:
--------------------------------------------------------------------------------
1 | Спасибо за регистрацию!
2 |
3 | Чтобы подтвердить свой аккаунт, переходи по ссылке.
--------------------------------------------------------------------------------
/tests/admins_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "net/http"
7 | "net/http/httptest"
8 | "strings"
9 |
10 | "github.com/gin-gonic/gin"
11 | "github.com/zhashkevych/creatly-backend/internal/domain"
12 | "go.mongodb.org/mongo-driver/bson/primitive"
13 | )
14 |
15 | func (s *APITestSuite) TestAdminCreateCourse() {
16 | router := gin.New()
17 | s.handler.Init(router.Group("/api"))
18 | r := s.Require()
19 |
20 | // populate DB data
21 | id := primitive.NewObjectID()
22 | schoolID := primitive.NewObjectID()
23 | adminEmail, password := "testAdmin@test.com", "qwerty123"
24 | passwordHash, err := s.hasher.Hash(password)
25 | s.NoError(err)
26 |
27 | _, err = s.db.Collection("admins").InsertOne(context.Background(), domain.Admin{
28 | ID: id,
29 | Email: adminEmail,
30 | Password: passwordHash,
31 | SchoolID: schoolID,
32 | })
33 | s.NoError(err)
34 |
35 | jwt, err := s.getJwt(id)
36 | s.NoError(err)
37 |
38 | adminCourseName := "admin course test name"
39 |
40 | name := fmt.Sprintf(`{"name":"%s"}`, adminCourseName)
41 |
42 | req, _ := http.NewRequest("POST", "/api/v1/admins/courses", strings.NewReader(name))
43 | req.Header.Set("Content-type", "application/json")
44 | req.Header.Set("Authorization", "Bearer "+jwt)
45 |
46 | resp := httptest.NewRecorder()
47 | router.ServeHTTP(resp, req)
48 |
49 | r.Equal(http.StatusCreated, resp.Result().StatusCode)
50 | }
51 |
52 | func (s *APITestSuite) TestAdminGetAllCourses() {
53 | router := gin.New()
54 | s.handler.Init(router.Group("/api"))
55 | r := s.Require()
56 |
57 | id := primitive.NewObjectID()
58 | schoolID := primitive.NewObjectID()
59 | adminEmail, password := "testAdmin@test.com", "qwerty123"
60 | passwordHash, err := s.hasher.Hash(password)
61 | s.NoError(err)
62 |
63 | _, err = s.db.Collection("admins").InsertOne(context.Background(), domain.Admin{
64 | ID: id,
65 | Email: adminEmail,
66 | Password: passwordHash,
67 | SchoolID: schoolID,
68 | })
69 | s.NoError(err)
70 |
71 | jwt, err := s.getJwt(id)
72 | s.NoError(err)
73 |
74 | req, _ := http.NewRequest("GET", "/api/v1/admins/courses", nil)
75 | req.Header.Set("Content-type", "application/json")
76 | req.Header.Set("Authorization", "Bearer "+jwt)
77 |
78 | resp := httptest.NewRecorder()
79 | router.ServeHTTP(resp, req)
80 |
81 | r.Equal(http.StatusOK, resp.Result().StatusCode)
82 | }
83 |
84 | func (s *APITestSuite) TestAdminGetCourseByID() {
85 | router := gin.New()
86 | s.handler.Init(router.Group("/api"))
87 | r := s.Require()
88 |
89 | id := primitive.NewObjectID()
90 | schoolID := primitive.NewObjectID()
91 | adminEmail, password := "testAdmin@test.com", "qwerty123"
92 | passwordHash, err := s.hasher.Hash(password)
93 | s.NoError(err)
94 |
95 | _, err = s.db.Collection("admins").InsertOne(context.Background(), domain.Admin{
96 | ID: id,
97 | Email: adminEmail,
98 | Password: passwordHash,
99 | SchoolID: schoolID,
100 | })
101 | s.NoError(err)
102 |
103 | jwt, err := s.getJwt(id)
104 | s.NoError(err)
105 |
106 | req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/admins/courses/%s", school.Courses[0].ID.Hex()), nil)
107 | req.Header.Set("Content-type", "application/json")
108 | req.Header.Set("Authorization", "Bearer "+jwt)
109 |
110 | resp := httptest.NewRecorder()
111 | router.ServeHTTP(resp, req)
112 |
113 | r.Equal(http.StatusOK, resp.Result().StatusCode)
114 | }
115 |
--------------------------------------------------------------------------------
/tests/courses_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io/ioutil"
7 | "net/http"
8 | "net/http/httptest"
9 |
10 | "github.com/gin-gonic/gin"
11 | "github.com/zhashkevych/creatly-backend/internal/domain"
12 | )
13 |
14 | type courseResponse struct {
15 | ID string `json:"id"`
16 | Name string `json:"name"`
17 | Code string `json:"code"`
18 | Description string `json:"description"`
19 | Color string `json:"color"`
20 | ImageURL string `json:"imageUrl"`
21 | CreatedAt string `json:"createdAt"`
22 | UpdatedAt string `json:"updatedAt"`
23 | Published bool `json:"published"`
24 | }
25 |
26 | type offerResponse struct {
27 | ID string `json:"id"`
28 | Name string `json:"name"`
29 | Description string `json:"description"`
30 | SchoolID string `json:"schoolId"`
31 | PackageIDs []string `json:"packages"`
32 | Price struct {
33 | Value uint `json:"value"`
34 | Currency string `json:"currency"`
35 | } `json:"price"`
36 | }
37 |
38 | func (s *APITestSuite) TestGetAllCourses() {
39 | router := gin.New()
40 | s.handler.Init(router.Group("/api"))
41 | r := s.Require()
42 |
43 | req, _ := http.NewRequest("GET", "/api/v1/courses", nil)
44 | req.Header.Set("Content-type", "application/json")
45 |
46 | resp := httptest.NewRecorder()
47 | router.ServeHTTP(resp, req)
48 |
49 | r.Equal(http.StatusOK, resp.Result().StatusCode)
50 |
51 | var respCourses struct {
52 | Data []courseResponse `json:"data"`
53 | }
54 |
55 | respData, err := ioutil.ReadAll(resp.Body)
56 | s.NoError(err)
57 |
58 | err = json.Unmarshal(respData, &respCourses)
59 | s.NoError(err)
60 |
61 | r.Equal(1, len(respCourses.Data))
62 | }
63 |
64 | func (s *APITestSuite) TestGetCourseById() {
65 | router := gin.New()
66 | s.handler.Init(router.Group("/api"))
67 | r := s.Require()
68 |
69 | req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/courses/%s", school.Courses[0].ID.Hex()), nil)
70 | req.Header.Set("Content-type", "application/json")
71 |
72 | resp := httptest.NewRecorder()
73 | router.ServeHTTP(resp, req)
74 |
75 | r.Equal(http.StatusOK, resp.Result().StatusCode)
76 |
77 | // Get Unpublished Course
78 | router = gin.New()
79 | s.handler.Init(router.Group("/api"))
80 | r = s.Require()
81 |
82 | req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/courses/%s", school.Courses[1].ID.Hex()), nil)
83 | req.Header.Set("Content-type", "application/json")
84 |
85 | resp = httptest.NewRecorder()
86 | router.ServeHTTP(resp, req)
87 |
88 | r.Equal(http.StatusBadRequest, resp.Result().StatusCode)
89 | }
90 |
91 | func (s *APITestSuite) TestGetCourseOffers() {
92 | router := gin.New()
93 | s.handler.Init(router.Group("/api"))
94 | r := s.Require()
95 |
96 | req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/courses/%s/offers", school.Courses[0].ID.Hex()), nil)
97 | req.Header.Set("Content-type", "application/json")
98 |
99 | resp := httptest.NewRecorder()
100 | router.ServeHTTP(resp, req)
101 |
102 | r.Equal(http.StatusOK, resp.Result().StatusCode)
103 |
104 | var respOffers struct {
105 | Data []offerResponse `json:"data"`
106 | }
107 |
108 | respData, err := ioutil.ReadAll(resp.Body)
109 | s.NoError(err)
110 |
111 | err = json.Unmarshal(respData, &respOffers)
112 | s.NoError(err)
113 |
114 | r.Equal(1, len(respOffers.Data))
115 | r.Equal(offers[0].(domain.Offer).Name, respOffers.Data[0].Name)
116 | r.Equal(offers[0].(domain.Offer).Description, respOffers.Data[0].Description)
117 | r.Equal(offers[0].(domain.Offer).Price.Value, respOffers.Data[0].Price.Value)
118 | r.Equal(offers[0].(domain.Offer).Price.Currency, respOffers.Data[0].Price.Currency)
119 | }
120 |
--------------------------------------------------------------------------------
/tests/data.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/zhashkevych/creatly-backend/internal/domain"
7 | "go.mongodb.org/mongo-driver/bson/primitive"
8 | )
9 |
10 | var (
11 | school = domain.School{
12 | ID: primitive.NewObjectID(),
13 | Courses: []domain.Course{
14 | {
15 | ID: primitive.NewObjectID(),
16 | Name: "Course #1",
17 | Published: true,
18 | },
19 | {
20 | ID: primitive.NewObjectID(),
21 | Name: "Course #2", // Unpublished course, shouldn't be available to student
22 | },
23 | },
24 | Settings: domain.Settings{
25 | Domains: []string{"http://localhost:1337", "workshop.zhashkevych.com", ""},
26 | Fondy: domain.Fondy{
27 | Connected: true,
28 | },
29 | },
30 | }
31 |
32 | packages = []interface{}{
33 | domain.Package{
34 | ID: primitive.NewObjectID(),
35 | Name: "Package #1",
36 | CourseID: school.Courses[0].ID,
37 | },
38 | }
39 |
40 | offers = []interface{}{
41 | domain.Offer{
42 | ID: primitive.NewObjectID(),
43 | Name: "Offer #1",
44 | Description: "Offer #1 Description",
45 | SchoolID: school.ID,
46 | PackageIDs: []primitive.ObjectID{packages[0].(domain.Package).ID},
47 | Price: domain.Price{Value: 6900, Currency: "USD"},
48 | },
49 | }
50 |
51 | promocodes = []interface{}{
52 | domain.PromoCode{
53 | ID: primitive.NewObjectID(),
54 | Code: "TEST25",
55 | DiscountPercentage: 25,
56 | ExpiresAt: time.Now().Add(time.Hour),
57 | OfferIDs: []primitive.ObjectID{offers[0].(domain.Offer).ID},
58 | SchoolID: school.ID,
59 | },
60 | }
61 |
62 | modules = []interface{}{
63 | domain.Module{
64 | ID: primitive.NewObjectID(),
65 | Name: "Module #1", // Free Module, should be available to anyone
66 | CourseID: school.Courses[0].ID,
67 | Published: true,
68 | Lessons: []domain.Lesson{
69 | {
70 | ID: primitive.NewObjectID(),
71 | Name: "Lesson #1",
72 | Published: true,
73 | },
74 | },
75 | },
76 | domain.Module{
77 | ID: primitive.NewObjectID(),
78 | Name: "Module #2", // Part of paid package, should be available only after purchase
79 | CourseID: school.Courses[0].ID,
80 | Published: true,
81 | PackageID: packages[0].(domain.Package).ID,
82 | Lessons: []domain.Lesson{
83 | {
84 | ID: primitive.NewObjectID(),
85 | Name: "Lesson #1",
86 | Published: true,
87 | },
88 | {
89 | ID: primitive.NewObjectID(),
90 | Name: "Lesson #2",
91 | Published: true,
92 | },
93 | },
94 | },
95 | domain.Module{
96 | ID: primitive.NewObjectID(),
97 | Name: "Module #1", // Part of unpublished course
98 | CourseID: school.Courses[1].ID,
99 | Published: true,
100 | PackageID: packages[0].(domain.Package).ID,
101 | Lessons: []domain.Lesson{
102 | {
103 | ID: primitive.NewObjectID(),
104 | Name: "Lesson #1",
105 | Published: true,
106 | },
107 | {
108 | ID: primitive.NewObjectID(),
109 | Name: "Lesson #2",
110 | Published: true,
111 | },
112 | },
113 | },
114 | }
115 | )
116 |
--------------------------------------------------------------------------------
/tests/fixtures/callback_approved.json:
--------------------------------------------------------------------------------
1 | {
2 | "rrn": "429417347068",
3 | "masked_card": "444455XXXXXX6666",
4 | "sender_cell_phone": "",
5 | "response_status": "success",
6 | "sender_account": "",
7 | "fee": "",
8 | "rectoken_lifetime": "",
9 | "reversal_amount": "0",
10 | "settlement_amount": "0",
11 | "actual_amount": "3324000",
12 | "order_status": "approved",
13 | "response_description": "",
14 | "verification_status": "",
15 | "order_time": "21.07.2017 15:20:27",
16 | "actual_currency": "RUB",
17 | "order_id": "6008153f3dab4fb0573d1f96",
18 | "parent_order_id": "",
19 | "merchant_data": "",
20 | "tran_type": "purchase",
21 | "eci": "",
22 | "settlement_date": "",
23 | "payment_system": "card",
24 | "rectoken": "",
25 | "approval_code": "027440",
26 | "merchant_id": 1396424,
27 | "settlement_currency": "",
28 | "payment_id": 51247263,
29 | "product_id": "",
30 | "currency": "RUB",
31 | "card_bin": 444455,
32 | "response_code": "",
33 | "card_type": "VISA",
34 | "amount": "3324000",
35 | "sender_email": "test@taskombank.eu",
36 | "signature": "11cb466a3a0bcb0f5542338fea326c827c395b20"
37 | }
--------------------------------------------------------------------------------
/tests/fixtures/callback_declined.json:
--------------------------------------------------------------------------------
1 | {
2 | "rrn": "429417347068",
3 | "masked_card": "444455XXXXXX6666",
4 | "sender_cell_phone": "",
5 | "response_status": "success",
6 | "sender_account": "",
7 | "fee": "",
8 | "rectoken_lifetime": "",
9 | "reversal_amount": "0",
10 | "settlement_amount": "0",
11 | "actual_amount": "3324000",
12 | "order_status": "declined",
13 | "response_description": "",
14 | "verification_status": "",
15 | "order_time": "21.07.2017 15:20:27",
16 | "actual_currency": "RUB",
17 | "order_id": "6008153f3dab4fb0573d1f96",
18 | "parent_order_id": "",
19 | "merchant_data": "",
20 | "tran_type": "purchase",
21 | "eci": "",
22 | "settlement_date": "",
23 | "payment_system": "card",
24 | "rectoken": "",
25 | "approval_code": "027440",
26 | "merchant_id": 1396424,
27 | "settlement_currency": "",
28 | "payment_id": 51247263,
29 | "product_id": "",
30 | "currency": "RUB",
31 | "card_bin": 444455,
32 | "response_code": "",
33 | "card_type": "VISA",
34 | "amount": "3324000",
35 | "sender_email": "test@taskombank.eu",
36 | "signature": "e745a506f1c695a9764bef031d3919844bba1403"
37 | }
--------------------------------------------------------------------------------
/tests/main_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "context"
5 | "os"
6 | "testing"
7 | "time"
8 |
9 | "github.com/stretchr/testify/suite"
10 | "github.com/zhashkevych/creatly-backend/internal/config"
11 | v1 "github.com/zhashkevych/creatly-backend/internal/delivery/http/v1"
12 | "github.com/zhashkevych/creatly-backend/internal/repository"
13 | "github.com/zhashkevych/creatly-backend/internal/service"
14 | "github.com/zhashkevych/creatly-backend/pkg/auth"
15 | "github.com/zhashkevych/creatly-backend/pkg/cache"
16 | "github.com/zhashkevych/creatly-backend/pkg/database/mongodb"
17 | emailmock "github.com/zhashkevych/creatly-backend/pkg/email/mock"
18 | "github.com/zhashkevych/creatly-backend/pkg/hash"
19 | "github.com/zhashkevych/creatly-backend/pkg/otp"
20 | "go.mongodb.org/mongo-driver/mongo"
21 | )
22 |
23 | var dbURI, dbName string
24 |
25 | func init() {
26 | dbURI = os.Getenv("TEST_DB_URI")
27 | dbName = os.Getenv("TEST_DB_NAME")
28 | }
29 |
30 | type APITestSuite struct {
31 | suite.Suite
32 |
33 | db *mongo.Database
34 | handler *v1.Handler
35 | services *service.Services
36 | repos *repository.Repositories
37 |
38 | hasher hash.PasswordHasher
39 | tokenManager auth.TokenManager
40 | mocks *mocks
41 | }
42 |
43 | type mocks struct {
44 | emailSender *emailmock.EmailSender
45 | otpGenerator *otp.MockGenerator
46 | }
47 |
48 | func TestAPISuite(t *testing.T) {
49 | if testing.Short() {
50 | t.Skip()
51 | }
52 |
53 | suite.Run(t, new(APITestSuite))
54 | }
55 |
56 | func (s *APITestSuite) SetupSuite() {
57 | if client, err := mongodb.NewClient(dbURI, "", ""); err != nil {
58 | s.FailNow("Failed to connect to mongo", err)
59 | } else {
60 | s.db = client.Database(dbName)
61 | }
62 |
63 | s.initMocks()
64 | s.initDeps()
65 |
66 | if err := s.populateDB(); err != nil {
67 | s.FailNow("Failed to populate DB", err)
68 | }
69 | }
70 |
71 | func (s *APITestSuite) TearDownSuite() {
72 | s.db.Client().Disconnect(context.Background()) //nolint:errcheck
73 | }
74 |
75 | func (s *APITestSuite) initDeps() {
76 | // Init domain deps
77 | repos := repository.NewRepositories(s.db)
78 | memCache := cache.NewMemoryCache()
79 | hasher := hash.NewSHA1Hasher("salt")
80 |
81 | tokenManager, err := auth.NewManager("signing_key")
82 | if err != nil {
83 | s.FailNow("Failed to initialize token manager", err)
84 | }
85 |
86 | services := service.NewServices(service.Deps{
87 |
88 | Repos: repos,
89 | Cache: memCache,
90 | Hasher: hasher,
91 | TokenManager: tokenManager,
92 | EmailSender: s.mocks.emailSender,
93 | EmailConfig: config.EmailConfig{
94 | Templates: config.EmailTemplates{
95 | Verification: "../templates/verification_email.html",
96 | PurchaseSuccessful: "../templates/purchase_successful.html",
97 | },
98 | Subjects: config.EmailSubjects{
99 | Verification: "Спасибо за регистрацию, %s!",
100 | PurchaseSuccessful: "Покупка прошла успешно!",
101 | },
102 | },
103 | AccessTokenTTL: time.Minute * 15,
104 | RefreshTokenTTL: time.Minute * 15,
105 | CacheTTL: int64(time.Minute.Seconds()),
106 | OtpGenerator: s.mocks.otpGenerator,
107 | VerificationCodeLength: 8,
108 | })
109 |
110 | s.repos = repos
111 | s.services = services
112 | s.handler = v1.NewHandler(services, tokenManager)
113 | s.hasher = hasher
114 | s.tokenManager = tokenManager
115 | }
116 |
117 | func (s *APITestSuite) initMocks() {
118 | s.mocks = &mocks{
119 | emailSender: new(emailmock.EmailSender),
120 | otpGenerator: new(otp.MockGenerator),
121 | }
122 | }
123 |
124 | func TestMain(m *testing.M) {
125 | rc := m.Run()
126 | os.Exit(rc)
127 | }
128 |
129 | func (s *APITestSuite) populateDB() error {
130 | _, err := s.db.Collection("schools").InsertOne(context.Background(), school)
131 | if err != nil {
132 | return err
133 | }
134 |
135 | _, err = s.db.Collection("packages").InsertMany(context.Background(), packages)
136 | if err != nil {
137 | return err
138 | }
139 |
140 | _, err = s.db.Collection("offers").InsertMany(context.Background(), offers)
141 | if err != nil {
142 | return err
143 | }
144 |
145 | _, err = s.db.Collection("modules").InsertMany(context.Background(), modules)
146 | if err != nil {
147 | return err
148 | }
149 |
150 | _, err = s.db.Collection("promocodes").InsertMany(context.Background(), promocodes)
151 | if err != nil {
152 | return err
153 | }
154 |
155 | return nil
156 | }
157 |
--------------------------------------------------------------------------------
/tests/payment_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "bytes"
5 | "context"
6 | "fmt"
7 | "io/ioutil"
8 | "net/http"
9 | "net/http/httptest"
10 |
11 | "github.com/gin-gonic/gin"
12 | "github.com/zhashkevych/creatly-backend/internal/domain"
13 | "github.com/zhashkevych/creatly-backend/pkg/email"
14 | "github.com/zhashkevych/creatly-backend/pkg/payment/fondy"
15 | "go.mongodb.org/mongo-driver/bson/primitive"
16 | )
17 |
18 | func (s *APITestSuite) TestFondyCallbackApproved() {
19 | router := gin.New()
20 | s.handler.Init(router.Group("/api"))
21 | r := s.Require()
22 |
23 | // populate DB data
24 | studentId := primitive.NewObjectID()
25 | studentEmail := "payment@test.com"
26 | studentName := "Test Payment"
27 | offerName := "Test Offer"
28 | _, err := s.db.Collection("students").InsertOne(context.Background(), domain.Student{
29 | ID: studentId,
30 | Email: studentEmail,
31 | Name: studentName,
32 | SchoolID: school.ID,
33 | Verification: domain.Verification{Verified: true},
34 | })
35 | s.NoError(err)
36 |
37 | id, _ := primitive.ObjectIDFromHex("6008153f3dab4fb0573d1f96")
38 | _, err = s.db.Collection("orders").InsertOne(context.Background(), domain.Order{
39 | ID: id,
40 | SchoolID: school.ID,
41 | Offer: domain.OrderOfferInfo{ID: offers[0].(domain.Offer).ID, Name: offerName},
42 | Student: domain.StudentInfoShort{ID: studentId, Email: studentEmail, Name: studentName},
43 | Status: "created",
44 | })
45 | s.NoError(err)
46 |
47 | s.mocks.emailSender.On("Send", email.SendEmailInput{
48 | To: studentEmail,
49 | Subject: "Покупка прошла успешно!",
50 | Body: fmt.Sprintf(`%s, спасибо большое за покупку "%s"!
51 |
52 | Надеюсь данный материал будет тебе полезен и интересен!
53 | Если у тебя возникают вопросы или ты хочешь поделиться своим отзывом - пиши мне письмо на zhashkevychmaksim@gmail.com.
54 | Мне крайне важен твой отзыв, чтобы улучшать материалы и делать курс максимально полезным!
55 |
56 |
57 |
58 | С уважением, Максим
`, studentName, offerName),
59 | }).Return(nil)
60 |
61 | file, err := ioutil.ReadFile("./fixtures/callback_approved.json")
62 | s.NoError(err)
63 |
64 | req, _ := http.NewRequest("POST", "/api/v1/callback/fondy", bytes.NewBuffer(file))
65 | req.Header.Set("Content-type", "application/json")
66 | req.Header.Set("User-Agent", fondy.UserAgent)
67 |
68 | resp := httptest.NewRecorder()
69 | router.ServeHTTP(resp, req)
70 |
71 | r.Equal(http.StatusOK, resp.Result().StatusCode)
72 |
73 | // Get Paid Lessons After Callback
74 | r = s.Require()
75 |
76 | jwt, err := s.getJwt(studentId)
77 | s.NoError(err)
78 |
79 | req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/students/modules/%s/content", modules[1].(domain.Module).ID.Hex()), nil)
80 | req.Header.Set("Content-type", "application/json")
81 | req.Header.Set("Authorization", "Bearer "+jwt)
82 |
83 | resp = httptest.NewRecorder()
84 | router.ServeHTTP(resp, req)
85 |
86 | r.Equal(http.StatusOK, resp.Result().StatusCode)
87 | }
88 |
89 | func (s *APITestSuite) TestFondyCallbackDeclined() {
90 | router := gin.New()
91 | s.handler.Init(router.Group("/api"))
92 | r := s.Require()
93 |
94 | // populate DB data
95 | studentId := primitive.NewObjectID()
96 | _, err := s.db.Collection("students").InsertOne(context.Background(), domain.Student{
97 | ID: studentId,
98 | SchoolID: school.ID,
99 | Verification: domain.Verification{Verified: true},
100 | })
101 | s.NoError(err)
102 |
103 | id, _ := primitive.ObjectIDFromHex("6008153f3dab4fb0573d1f97")
104 | _, err = s.db.Collection("orders").InsertOne(context.Background(), domain.Order{
105 | ID: id,
106 | SchoolID: school.ID,
107 | Offer: domain.OrderOfferInfo{ID: offers[0].(domain.Offer).ID},
108 | Student: domain.StudentInfoShort{ID: studentId},
109 | Status: "created",
110 | })
111 | s.NoError(err)
112 |
113 | file, err := ioutil.ReadFile("./fixtures/callback_declined.json")
114 | s.NoError(err)
115 |
116 | req, _ := http.NewRequest("POST", "/api/v1/callback/fondy", bytes.NewBuffer(file))
117 | req.Header.Set("Content-type", "application/json")
118 | req.Header.Set("User-Agent", fondy.UserAgent)
119 |
120 | resp := httptest.NewRecorder()
121 | router.ServeHTTP(resp, req)
122 |
123 | r.Equal(http.StatusOK, resp.Result().StatusCode)
124 |
125 | // Get Paid Lessons After Callback
126 | r = s.Require()
127 |
128 | jwt, err := s.getJwt(studentId)
129 | s.NoError(err)
130 |
131 | req, _ = http.NewRequest("GET", fmt.Sprintf("/api/v1/students/modules/%s/content", modules[1].(domain.Module).ID.Hex()), nil)
132 | req.Header.Set("Content-type", "application/json")
133 | req.Header.Set("Authorization", "Bearer "+jwt)
134 |
135 | resp = httptest.NewRecorder()
136 | router.ServeHTTP(resp, req)
137 |
138 | r.Equal(http.StatusForbidden, resp.Result().StatusCode)
139 | }
140 |
--------------------------------------------------------------------------------
/tests/promocodes_test.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "fmt"
5 | "net/http"
6 | "net/http/httptest"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/zhashkevych/creatly-backend/internal/domain"
10 | )
11 |
12 | func (s *APITestSuite) TestGetPromoCode() {
13 | router := gin.New()
14 | s.handler.Init(router.Group("/api"))
15 | r := s.Require()
16 |
17 | req, _ := http.NewRequest("GET", fmt.Sprintf("/api/v1/promocodes/%s", promocodes[0].(domain.PromoCode).Code), nil)
18 | req.Header.Set("Content-type", "application/json")
19 |
20 | resp := httptest.NewRecorder()
21 | router.ServeHTTP(resp, req)
22 |
23 | r.Equal(http.StatusOK, resp.Result().StatusCode)
24 | }
25 |
26 | func (s *APITestSuite) TestGetPromoCodeInvalid() {
27 | router := gin.New()
28 | s.handler.Init(router.Group("/api"))
29 | r := s.Require()
30 |
31 | req, _ := http.NewRequest("GET", "/api/v1/promocodes/CODE123", nil)
32 | req.Header.Set("Content-type", "application/json")
33 |
34 | resp := httptest.NewRecorder()
35 | router.ServeHTTP(resp, req)
36 |
37 | r.Equal(http.StatusBadRequest, resp.Result().StatusCode)
38 | }
39 |
--------------------------------------------------------------------------------