diff --git a/resources/db/migration/V1.2.0.1__create_reports.sql b/resources/db/migration/V1.2.0.1__create_reports.sql new file mode 100644 index 0000000..d74143e --- /dev/null +++ b/resources/db/migration/V1.2.0.1__create_reports.sql @@ -0,0 +1,19 @@ +create table bug_reports +( + uuid varchar(36) not null primary key, + created timestamptz not null, + operator_uuid varchar(36) not null, + email varchar not null, + center_uuid varchar not null, + center_name varchar not null, + center_address varchar not null, + subject text not null, + message text +); + +alter table operators + add email varchar, + add bug_reports_receiver varchar; + +alter table centers + add email varchar; \ No newline at end of file diff --git a/resources/db/migration/V1.2.0.2__add_system_settings.sql b/resources/db/migration/V1.2.0.2__add_system_settings.sql new file mode 100644 index 0000000..2b47927 --- /dev/null +++ b/resources/db/migration/V1.2.0.2__add_system_settings.sql @@ -0,0 +1,22 @@ +create table system_settings +( + config_key varchar not null primary key, + config_value text +); + +INSERT INTO public.system_settings (config_key, config_value) VALUES ('reports.email.default', 'dirk.reske@t-systems.com'); +INSERT INTO public.system_settings (config_key, config_value) VALUES ('reports.email.subject', 'Meldungen zur Ihren Teststellen'); +INSERT INTO public.system_settings (config_key, config_value) VALUES ('reports.email.template', 'Guten Tag,
+
+für Ihre Teststellen wurden folgende Fehler gemeldet.
+
+{{range $center, $reports := .Centers}}Meldungen für die Teststelle {{(index $reports 0).CenterName}} ({{(index $reports 0).CenterAddress}})
+ +
+{{end}} +
+Viele Grüße
+Ihr Schnelltestportal.de Team'); \ No newline at end of file diff --git a/src/api/centers.go b/src/api/centers.go index c4db103..0b8fa31 100644 --- a/src/api/centers.go +++ b/src/api/centers.go @@ -10,13 +10,12 @@ import ( "com.t-systems-mms.cwa/repositories" "com.t-systems-mms.cwa/services" "encoding/csv" - "encoding/json" "github.com/go-chi/chi" "github.com/go-chi/jwtauth" + "github.com/go-playground/validator" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" "github.com/sirupsen/logrus" - "io" "net/http" "strconv" "strings" @@ -55,9 +54,12 @@ type Centers struct { geocoder geocoding.Geocoder operatorsService services.Operators centersRepository repositories.Centers + bugReportsService services.BugReports + validate *validator.Validate } func NewCentersAPI(centersService services.Centers, centersRepository repositories.Centers, + bugReportsService services.BugReports, operatorsService services.Operators, geocoder geocoding.Geocoder, auth *jwtauth.JWTAuth) *Centers { centers := &Centers{ Router: chi.NewRouter(), @@ -65,11 +67,14 @@ func NewCentersAPI(centersService services.Centers, centersRepository repositori centersRepository: centersRepository, operatorsService: operatorsService, geocoder: geocoder, + bugReportsService: bugReportsService, + validate: validator.New(), } // public endpoints centers.Get("/", api.Handle(centers.FindCenters)) centers.Get("/bounds", api.Handle(centers.Geocode)) + centers.Post("/{uuid}/report", api.Handle(centers.createBugReport)) centers.Group(func(r chi.Router) { r.Use(jwtauth.Verifier(auth)) @@ -226,11 +231,7 @@ func (c *Centers) AdminGetCentersCSV(w http.ResponseWriter, r *http.Request) { func (c *Centers) ImportCenters(_ http.ResponseWriter, r *http.Request) (interface{}, error) { var importData model.ImportCenterRequest - buffer, err := io.ReadAll(r.Body) - if err != nil { - return nil, err - } - if err := json.Unmarshal(buffer, &importData); err != nil { + if err := api.ParseRequestBody(r, c.validate, &importData); err != nil { return nil, err } @@ -276,6 +277,18 @@ func (c *Centers) deleteCenterByReference(_ http.ResponseWriter, r *http.Request return nil, c.centersRepository.Delete(r.Context(), center) } +// createBugReport creates a bug report for the specified center +func (c *Centers) createBugReport(_ http.ResponseWriter, r *http.Request) (interface{}, error) { + uuid := chi.URLParam(r, "uuid") + var request model.CreateBugReportRequestDTO + if err := api.ParseRequestBody(r, c.validate, &request); err != nil { + return nil, err + } + + _, err := c.bugReportsService.CreateBugReport(r.Context(), uuid, request.Subject, request.Message) + return nil, err +} + func (c *Centers) DeleteCenter(_ http.ResponseWriter, r *http.Request) (interface{}, error) { centerUUID := chi.URLParam(r, "uuid") logrus.WithField("uuid", centerUUID).Trace("DeleteCenter") diff --git a/src/api/model/bugreport.go b/src/api/model/bugreport.go new file mode 100644 index 0000000..c29d80a --- /dev/null +++ b/src/api/model/bugreport.go @@ -0,0 +1,6 @@ +package model + +type CreateBugReportRequestDTO struct { + Subject string `json:"subject" validate:"required,max=160"` + Message *string `json:"message" validate:"omitempty,max=160"` +} diff --git a/src/api/model/centers.go b/src/api/model/centers.go index b57cde3..31c02d6 100644 --- a/src/api/model/centers.go +++ b/src/api/model/centers.go @@ -17,7 +17,7 @@ type FindCentersResult struct { } type ImportCenterRequest struct { - Centers []EditCenterDTO `json:"centers"` + Centers []EditCenterDTO `json:"centers" validate:"dive"` DeleteAll bool `json:"deleteAll"` } @@ -30,6 +30,7 @@ type ImportCenterResult struct { type CenterSummaryDTO struct { UUID string `json:"uuid"` Name string `json:"name"` + Email *string `json:"email"` Website *string `json:"website"` Coordinates *CoordinatesDTO `json:"coordinates"` Logo *string `json:"logo"` @@ -58,6 +59,7 @@ func (CenterSummaryDTO) MapFromDomain(center *domain.Center) *CenterSummaryDTO { return &CenterSummaryDTO{ UUID: center.UUID, Name: center.Name, + Email: center.Email, Website: center.Website, Coordinates: CoordinatesDTO{}.MapFromModel(¢er.Coordinates), Logo: getCenterLogo(center), @@ -121,12 +123,13 @@ func MapToCenterDTOs(centers []domain.Center) []CenterDTO { type EditCenterDTO struct { UserReference *string `json:"userReference"` Name string `json:"name" validate:"required"` + Email *string `json:"email" validate:"omitempty,email"` Website *string `json:"website"` Address string `json:"address" validate:"required"` - OpeningHours []string `json:"openingHours"` + OpeningHours []string `json:"openingHours" validate:"dive,max=64"` AddressNote *string `json:"addressNote"` - Appointment *string `json:"appointment"` - TestKinds []string `json:"testKinds"` + Appointment *string `json:"appointment" validate:"omitempty,oneof=Required NotRequired Possible"` + TestKinds []string `json:"testKinds" validate:"dive,oneof=Antigen PCR Vaccination Antibody"` DCC *bool `json:"dcc"` EnterDate *string `json:"enterDate"` LeaveDate *string `json:"leaveDate"` @@ -160,6 +163,7 @@ func (c EditCenterDTO) MapToDomain() domain.Center { DCC: c.DCC, EnterDate: enterDate, LeaveDate: leaveDate, + Email: c.Email, } } diff --git a/src/api/model/operators.go b/src/api/model/operators.go index 31ef684..d55dbf8 100644 --- a/src/api/model/operators.go +++ b/src/api/model/operators.go @@ -6,6 +6,7 @@ type OperatorDTO struct { UUID string `json:"uuid"` OperatorNumber *string `json:"operatorNumber"` Name string `json:"name"` + Email *string `json:"email"` Logo *string `json:"logo"` MarkerIcon *string `json:"markerIcon"` } @@ -33,5 +34,6 @@ func MapToOperatorDTO(operator *domain.Operator) *OperatorDTO { Name: operator.Name, Logo: logo, MarkerIcon: markerIcon, + Email: operator.Email, } } diff --git a/src/cmd/backend/config.go b/src/cmd/backend/config.go index 16c89c0..f8c8438 100644 --- a/src/cmd/backend/config.go +++ b/src/cmd/backend/config.go @@ -2,6 +2,7 @@ package main import ( "com.t-systems-mms.cwa/external/geocoding" + "com.t-systems-mms.cwa/services" "errors" "fmt" "github.com/hashicorp/vault/api" @@ -17,6 +18,8 @@ type Config struct { Database DatabaseConfig Google geocoding.GoogleGeocoderConfig Authentication AuthenticationConfig + BugReports services.BugReportConfig + Email services.EmailConfig } type DatabaseConfig struct { @@ -105,6 +108,34 @@ func LoadConfig() error { appConfig.Database.IdlePoolSize = 10 } + // E-Mail + if err := readStringSecret(logicalClient, backend+"/data/email", "smtp-host", + &appConfig.Email.SmtpHost); err != nil { + return err + } + if err := readIntSecret(logicalClient, backend+"/data/email", "smtp-port", + &appConfig.Email.SmtpPort); err != nil { + return err + } + if err := readStringSecret(logicalClient, backend+"/data/email", "smtp-user", + &appConfig.Email.SmtpUser); err != nil { + return err + } + if err := readStringSecret(logicalClient, backend+"/data/email", "smtp-password", + &appConfig.Email.SmtpPassword); err != nil { + return err + } + if err := readStringSecret(logicalClient, backend+"/data/email", "from", + &appConfig.Email.From); err != nil { + return err + } + + // Bug reports + if err := readIntSecret(logicalClient, backend+"/data/reports", "interval", + &appConfig.BugReports.Interval); err != nil { + appConfig.BugReports.Interval = 24 * 60 + } + // Authentication if err := readStringSecret(logicalClient, backend+"/data/authentication", "jwks-url", &appConfig.Authentication.JwksUrl); err != nil { diff --git a/src/cmd/backend/main.go b/src/cmd/backend/main.go index b427c4d..3f5cc38 100644 --- a/src/cmd/backend/main.go +++ b/src/cmd/backend/main.go @@ -69,11 +69,19 @@ func main() { sqlDB.SetMaxOpenConns(appConfig.Database.MaxOpenConns) sqlDB.SetConnMaxLifetime(time.Duration(appConfig.Database.ConnMaxLifetime) * time.Minute) + settingsRepository := repositories.NewSystemSettingsRepository(db) + centersRepository := repositories.NewCentersRepository(db) operatorsRepository := repositories.NewOperatorsRepository(db) operatorsService := services.NewOperatorsService(operatorsRepository) centersService := services.NewCentersService(centersRepository, operatorsRepository, operatorsService, geocoder) + mailService := services.NewMailService(appConfig.Email) + + bugReportsRepository := repositories.NewBugReportsRepository(db) + bugReportsService := services.NewBugReportsService(appConfig.BugReports, + mailService, centersRepository, bugReportsRepository, settingsRepository) + // configure authentication jwksSource := jwks.NewWebSource(appConfig.Authentication.JwksUrl) jwksClient := jwks.NewDefaultClient(jwksSource, time.Hour, 12*time.Hour) @@ -88,7 +96,7 @@ func main() { router := chi.NewRouter() router.Use(middleware.DefaultLogger) router.Handle("/metrics", initMetricsHandler(centersRepository, operatorsRepository)) - router.Mount("/api/centers", api.NewCentersAPI(centersService, centersRepository, operatorsService, geocoder, tokenAuth)) + router.Mount("/api/centers", api.NewCentersAPI(centersService, centersRepository, bugReportsService, operatorsService, geocoder, tokenAuth)) router.Mount("/api/operators", api.NewOperatorsAPI(operatorsRepository, operatorsService, tokenAuth)) server := &http.Server{ @@ -109,6 +117,8 @@ func main() { serverWaitHandle.Done() }() + go bugReportsService.PublishScheduler() + signals := make(chan os.Signal, 1) signal.Notify(signals, syscall.SIGTERM, syscall.SIGINT) <-signals diff --git a/src/core/api/errors.go b/src/core/api/errors.go index b1aa724..fcbe57b 100644 --- a/src/core/api/errors.go +++ b/src/core/api/errors.go @@ -38,7 +38,7 @@ func createValidationErrorResponse(errors validator.ValidationErrors) Validation for _, fieldError := range errors { fields = append(fields, ValidationFieldError{ Field: fieldError.Field(), - Validation: fieldError.Tag(), + Validation: fieldError.Tag() + "=" + fieldError.Param(), }) } return ValidationErrorResponse{ diff --git a/src/core/api/requesthandler.go b/src/core/api/requesthandler.go index 9897603..3d9b0b8 100644 --- a/src/core/api/requesthandler.go +++ b/src/core/api/requesthandler.go @@ -7,6 +7,7 @@ import ( "github.com/go-playground/validator" "github.com/sirupsen/logrus" "gorm.io/gorm" + "io" "net/http" "time" ) @@ -78,3 +79,19 @@ func WriteResponse(writer http.ResponseWriter, code int, body interface{}) { } } } + +// ParseRequestBody parses the request body, unmarshals the json into target and validates it +func ParseRequestBody(r *http.Request, validate *validator.Validate, target interface{}) error { + if data, err := io.ReadAll(r.Body); err == nil { + if err := json.Unmarshal(data, target); err == nil { + if vErr := validate.Struct(target); vErr != nil { + return vErr + } + } else { + return err + } + } else { + return err + } + return nil +} diff --git a/src/domain/bugreports.go b/src/domain/bugreports.go new file mode 100644 index 0000000..705fcef --- /dev/null +++ b/src/domain/bugreports.go @@ -0,0 +1,21 @@ +package domain + +import "time" + +const ( + ReportReceiverCenter = "center" + ReportReceiverOperator = "operator" +) + +type BugReport struct { + UUID string `gorm:"primaryKey"` + Created time.Time + Email string + OperatorUUID string + Operator Operator + CenterUUID string + CenterName string + CenterAddress string + Subject string + Message *string +} diff --git a/src/domain/centers.go b/src/domain/centers.go index b2ad67f..e034830 100644 --- a/src/domain/centers.go +++ b/src/domain/centers.go @@ -81,6 +81,7 @@ type Center struct { Ranking float64 Zip *string Region *string + Email *string `validate:"omitempty,email"` } type CenterWithDistance struct { diff --git a/src/domain/operator.go b/src/domain/operator.go index 2df0478..54f44da 100644 --- a/src/domain/operator.go +++ b/src/domain/operator.go @@ -1,10 +1,12 @@ package domain type Operator struct { - UUID string `gorm:"primaryKey"` - Subject *string - OperatorNumber *string - Name string - Logo *string - MarkerIcon *string + UUID string `gorm:"primaryKey"` + Subject *string + OperatorNumber *string + Name string + Logo *string + MarkerIcon *string + Email *string + BugReportsReceiver *string } diff --git a/src/domain/systemsettings.go b/src/domain/systemsettings.go new file mode 100644 index 0000000..c99ca88 --- /dev/null +++ b/src/domain/systemsettings.go @@ -0,0 +1,6 @@ +package domain + +type SystemSetting struct { + ConfigKey string + ConfigValue *string +} diff --git a/src/go.mod b/src/go.mod index fb34dfe..0698d29 100644 --- a/src/go.mod +++ b/src/go.mod @@ -20,6 +20,7 @@ require ( github.com/sirupsen/logrus v1.8.1 github.com/stretchr/testify v1.7.0 github.com/vincent-petithory/dataurl v0.0.0-20191104211930-d1553a71de50 + github.com/xhit/go-simple-mail/v2 v2.10.0 // indirect golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a // indirect golang.org/x/text v0.3.6 // indirect googlemaps.github.io/maps v1.3.2 diff --git a/src/go.sum b/src/go.sum index 5e9e532..994d968 100644 --- a/src/go.sum +++ b/src/go.sum @@ -470,6 +470,10 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= github.com/vincent-petithory/dataurl v0.0.0-20191104211930-d1553a71de50 h1:uxE3GYdXIOfhMv3unJKETJEhw78gvzuQqRX/rVirc2A= github.com/vincent-petithory/dataurl v0.0.0-20191104211930-d1553a71de50/go.mod h1:FHafX5vmDzyP+1CQATJn7WFKc9CvnvxyvZy6I1MrG/U= +github.com/xhit/go-simple-mail v2.2.2+incompatible h1:Hm2VGfLqiQJ/NnC8SYsrPOPyVYIlvP2kmnotP4RIV74= +github.com/xhit/go-simple-mail v2.2.2+incompatible/go.mod h1:I8Ctg6vIJZ+Sv7k/22M6oeu/tbFumDY0uxBuuLbtU7Y= +github.com/xhit/go-simple-mail/v2 v2.10.0 h1:nib6RaJ4qVh5HD9UE9QJqnUZyWp3upv+Z6CFxaMj0V8= +github.com/xhit/go-simple-mail/v2 v2.10.0/go.mod h1:kA1XbQfCI4JxQ9ccSN6VFyIEkkugOm7YiPkA5hKiQn4= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= diff --git a/src/repositories/bugreports.go b/src/repositories/bugreports.go new file mode 100644 index 0000000..a86bba2 --- /dev/null +++ b/src/repositories/bugreports.go @@ -0,0 +1,57 @@ +package repositories + +import ( + "com.t-systems-mms.cwa/core/util" + "com.t-systems-mms.cwa/domain" + "context" + "github.com/google/uuid" + "gorm.io/gorm" + "time" +) + +type BugReports interface { + Repository + Save(ctx context.Context, center *domain.BugReport) error + FindAll(ctx context.Context) ([]domain.BugReport, error) + DeleteAll(ctx context.Context) error + DeleteByReceiver(ctx context.Context, receiver string) error +} + +type bugReportsRepository struct { + postgresqlRepository + db *gorm.DB + // Save persists the given center +} + +func NewBugReportsRepository(db *gorm.DB) BugReports { + return &bugReportsRepository{ + postgresqlRepository: postgresqlRepository{db: db}, + db: db, + } +} + +func (b *bugReportsRepository) Save(ctx context.Context, report *domain.BugReport) error { + if util.IsNilOrEmpty(&report.UUID) { + if id, err := uuid.NewUUID(); err != nil { + return err + } else { + report.UUID = id.String() + report.Created = time.Now() + } + } + return b.db.Save(report).Error +} + +func (b *bugReportsRepository) DeleteAll(ctx context.Context) error { + return b.GetTX(ctx).Exec("DELETE FROM bug_reports").Error +} + +func (b *bugReportsRepository) DeleteByReceiver(ctx context.Context, receiver string) error { + return b.GetTX(ctx).Exec("DELETE FROM bug_reports where email = ?", receiver).Error +} + +func (b *bugReportsRepository) FindAll(ctx context.Context) ([]domain.BugReport, error) { + var reports []domain.BugReport + err := b.GetTX(ctx).Find(&reports).Error + return reports, err +} diff --git a/src/repositories/centers.go b/src/repositories/centers.go index 908de81..030a53d 100644 --- a/src/repositories/centers.go +++ b/src/repositories/centers.go @@ -30,8 +30,7 @@ type CenterStatistics struct { } type Centers interface { - UseTransaction(ctx context.Context, fn func(ctx context.Context) error) error - + Repository FindByUUID(ctx context.Context, uuid string) (domain.Center, error) Delete(ctx context.Context, center domain.Center) error @@ -80,7 +79,10 @@ func (r *centersRepository) Delete(ctx context.Context, center domain.Center) er func (r *centersRepository) FindByUUID(ctx context.Context, uuid string) (domain.Center, error) { var center domain.Center - err := r.db.Model(&domain.Center{}).Where("uuid = ?", uuid).First(¢er).Error + err := r.db.Model(&domain.Center{}). + Preload("Operator"). + Where("uuid = ?", uuid). + First(¢er).Error return center, err } diff --git a/src/repositories/operators.go b/src/repositories/operators.go index af5c5c1..55d9455 100644 --- a/src/repositories/operators.go +++ b/src/repositories/operators.go @@ -72,21 +72,28 @@ func (r *operatorsRepository) GetOrCreateByToken(ctx context.Context, token jwt. if errors.Is(err, gorm.ErrRecordNotFound) { // Create the operator with default settings name := "" - nameEntry, ok := token.Get("name") - if ok { - name = nameEntry.(string) + if entry, ok := token.Get("name"); ok { + name = entry.(string) } - username := "" - usernameEntry, ok := token.Get("preferred_username") - if ok { - username = usernameEntry.(string) + + operatorNumber := "" + if entry, ok := token.Get("preferred_username"); ok { + operatorNumber = entry.(string) + } + + email := "" + if entry, ok := token.Get("email"); ok { + email = entry.(string) } + receiver := domain.ReportReceiverOperator return r.Save(ctx, domain.Operator{ - UUID: id.String(), - OperatorNumber: &username, - Name: name, - Subject: &subject, + UUID: id.String(), + OperatorNumber: &operatorNumber, + Name: name, + Subject: &subject, + BugReportsReceiver: &receiver, + Email: &email, }) } return operator, nil diff --git a/src/repositories/postgresql.go b/src/repositories/postgresql.go index 74efcdc..1a1444e 100644 --- a/src/repositories/postgresql.go +++ b/src/repositories/postgresql.go @@ -7,6 +7,10 @@ import ( const transactionKey = "transactionKey" +type Repository interface { + UseTransaction(ctx context.Context, fn func(ctx context.Context) error) error +} + type postgresqlRepository struct { db *gorm.DB } diff --git a/src/repositories/systemsettings.go b/src/repositories/systemsettings.go new file mode 100644 index 0000000..5beeac7 --- /dev/null +++ b/src/repositories/systemsettings.go @@ -0,0 +1,53 @@ +package repositories + +import ( + "com.t-systems-mms.cwa/domain" + "context" + "errors" + "gorm.io/gorm" +) + +type SystemSettings interface { + FindValue(ctx context.Context, key string) (*string, error) + FindValueWithDefault(ctx context.Context, key, value string) (string, error) +} + +type systemSettingsRepository struct { + postgresqlRepository + db *gorm.DB + // Save persists the given center +} + +func NewSystemSettingsRepository(db *gorm.DB) SystemSettings { + return &systemSettingsRepository{ + postgresqlRepository: postgresqlRepository{db: db}, + db: db, + } +} + +func (s *systemSettingsRepository) FindValue(ctx context.Context, key string) (*string, error) { + var setting domain.SystemSetting + err := s.GetTX(ctx). + Model(domain.SystemSetting{}). + Where("config_key = ?", key). + First(&setting). + Error + + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + + return setting.ConfigValue, err +} + +func (s *systemSettingsRepository) FindValueWithDefault(ctx context.Context, key, value string) (string, error) { + val, err := s.FindValue(ctx, key) + if err != nil { + return "", err + } + + if val == nil || *val == "" { + return value, nil + } + return *val, nil +} diff --git a/src/services/bugreports.go b/src/services/bugreports.go new file mode 100644 index 0000000..3865e10 --- /dev/null +++ b/src/services/bugreports.go @@ -0,0 +1,187 @@ +package services + +import ( + "com.t-systems-mms.cwa/domain" + "com.t-systems-mms.cwa/repositories" + "context" + "errors" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" + "github.com/sirupsen/logrus" + "strings" + "text/template" + "time" +) + +var ( + createdBugReportsCount = promauto.NewCounter(prometheus.CounterOpts{ + Name: "cwa_map_bug_reports_count", + Help: "The total count of created bug reports", + }) +) + +const ( + ConfigDefaultReportsEmail = "reports.email.default" + ConfigReportsEmailTemplate = "reports.email.template" + ConfigReportsEmailSubject = "reports.email.subject" +) + +type BugReportConfig struct { + Interval int +} + +type BugReports interface { + CreateBugReport(ctx context.Context, centerUUID, subject string, message *string) (domain.BugReport, error) + + //PublishBugReports sends all pending bug reports the appropriate receivers. + //After sending the reports will be permanently deleted + PublishBugReports(ctx context.Context) error + + //PublishScheduler starts the scheduler for regularly sending bug reports + PublishScheduler() +} + +type bugReportsService struct { + config BugReportConfig + mailService MailService + centersRepository repositories.Centers + bugReportsRepository repositories.BugReports + settingsRepository repositories.SystemSettings +} + +func NewBugReportsService(config BugReportConfig, + mailService MailService, + centersRepository repositories.Centers, + bugReportsRepository repositories.BugReports, + settingsRepository repositories.SystemSettings) BugReports { + + return &bugReportsService{ + config: config, + mailService: mailService, + centersRepository: centersRepository, + bugReportsRepository: bugReportsRepository, + settingsRepository: settingsRepository, + } +} + +func (s *bugReportsService) CreateBugReport(ctx context.Context, centerUUID, subject string, message *string) (domain.BugReport, error) { + // check if center exists + center, err := s.centersRepository.FindByUUID(ctx, centerUUID) + if err != nil { + return domain.BugReport{}, err + } + + email := center.Operator.Email + if center.Operator.BugReportsReceiver != nil && *center.Operator.BugReportsReceiver == "center" { + email = center.Email + } + + if email == nil { + if value, err := s.settingsRepository.FindValue(ctx, ConfigDefaultReportsEmail); err != nil { + logrus.WithError(err).WithField("key", ConfigDefaultReportsEmail).Error("Error getting config value") + return domain.BugReport{}, errors.New("invalid config") + } else if value == nil { + logrus.WithField("key", ConfigDefaultReportsEmail).Error("Config not set") + return domain.BugReport{}, errors.New("invalid config") + } else { + email = value + } + } + + report := domain.BugReport{ + Created: time.Now(), + Email: *email, + CenterUUID: centerUUID, + OperatorUUID: center.OperatorUUID, + CenterName: center.Name, + CenterAddress: center.Address, + Subject: subject, + Message: message, + } + + err = s.bugReportsRepository.Save(ctx, &report) + createdBugReportsCount.Inc() + return report, err +} + +func (s *bugReportsService) PublishBugReports(ctx context.Context) error { + reports, err := s.bugReportsRepository.FindAll(ctx) + if err != nil { + return err + } + + // getting template + mailTemplateString, err := s.settingsRepository.FindValue(ctx, ConfigReportsEmailTemplate) + if err != nil { + return err + } else if mailTemplateString == nil { + return errors.New("missing template") + } + + mailSubject, err := s.settingsRepository.FindValue(ctx, ConfigReportsEmailSubject) + if err != nil { + return err + } else if mailSubject == nil { + return errors.New("missing subject") + } + + mailTemplate, err := template.New("report").Parse(*mailTemplateString) + if err != nil { + return err + } + + // collect reports by receiver + receivers := make(map[string][]domain.BugReport) + for _, report := range reports { + if _, ok := receivers[report.Email]; !ok { + receivers[report.Email] = make([]domain.BugReport, 0) + } + receivers[report.Email] = append(receivers[report.Email], report) + } + + for receiver, reports := range receivers { + // collect reports by center + centers := make(map[string][]domain.BugReport) + for _, report := range reports { + if _, ok := centers[report.CenterUUID]; !ok { + centers[report.CenterUUID] = make([]domain.BugReport, 0) + } + centers[report.CenterUUID] = append(centers[report.CenterUUID], report) + } + + // render template + buffer := strings.Builder{} + err := mailTemplate.Execute(&buffer, struct { + Centers map[string][]domain.BugReport + }{ + Centers: centers, + }) + if err != nil { + return err + } + + // sending mail + err = s.mailService.SendMail(ctx, receiver, *mailSubject, "text/html", buffer.String()) + if err != nil { + return err + } + + // delete reports for this receiver + err = s.bugReportsRepository.DeleteByReceiver(ctx, receiver) + if err != nil { + return err + } + } + + return nil +} + +func (s *bugReportsService) PublishScheduler() { + logrus.WithFields(logrus.Fields{"interval": s.config.Interval}).Info("PublishScheduler started") + for { + if err := s.PublishBugReports(context.Background()); err != nil { + logrus.WithError(err).Error("Error publishing reports") + } + time.Sleep(time.Duration(s.config.Interval) * time.Minute) + } +} diff --git a/src/services/csvparser.go b/src/services/csvparser.go index 25b409b..de5aba0 100644 --- a/src/services/csvparser.go +++ b/src/services/csvparser.go @@ -23,6 +23,7 @@ const ( cityIndex = 6 enterDateIndex = 8 leaveDateIndex = 9 + emailIndex = 12 openingHoursIndex = 13 appointmentIndex = 14 testKindsIndex = 15 @@ -143,6 +144,13 @@ func (c *CsvParser) parseCsvRow(entry []string) ImportCenterResult { var website *string if entry := strings.TrimSpace(entry[websiteIndex]); entry != "" && strings.ToLower(entry) != "null" { website = &entry + } else { + result.Errors = append(result.Errors, "invalid email address: "+entry) + } + + var email *string + if entry := strings.TrimSpace(entry[emailIndex]); entry != "" && strings.ToLower(entry) != "null" { + email = &entry } dcc := strings.ToLower(strings.TrimSpace(entry[dccIndex])) == "ja" @@ -173,6 +181,7 @@ func (c *CsvParser) parseCsvRow(entry []string) ImportCenterResult { result.Center = domain.Center{ UserReference: userReference, Name: strings.TrimSpace(entry[nameIndex]), + Email: email, Website: website, Coordinates: domain.Coordinates{ Longitude: 0, diff --git a/src/services/mail.go b/src/services/mail.go new file mode 100644 index 0000000..a29c27c --- /dev/null +++ b/src/services/mail.go @@ -0,0 +1,69 @@ +package services + +import ( + "context" + mail "github.com/xhit/go-simple-mail/v2" + "net/smtp" +) + +type EmailConfig struct { + SmtpHost string + SmtpPort int + SmtpUser string + SmtpPassword string + From string +} + +type MailService interface { + // SendMail send the mail with the given receiver, subject and body to the configured mail server + SendMail(ctx context.Context, receiver, subject, contentType, body string) error +} + +type mailService struct { + config EmailConfig + auth smtp.Auth +} + +func NewMailService(config EmailConfig) MailService { + return &mailService{ + config: config, + auth: smtp.PlainAuth("", config.SmtpUser, config.SmtpPassword, config.SmtpHost), + } +} + +func (m *mailService) SendMail(ctx context.Context, receiver, subject, contentType, body string) error { + server := mail.NewSMTPClient() + server.Host = m.config.SmtpHost + server.Port = m.config.SmtpPort + server.Username = m.config.SmtpUser + server.Password = m.config.SmtpPassword + server.Encryption = mail.EncryptionSTARTTLS + server.Authentication = mail.AuthLogin + server.KeepAlive = true + + smtpClient, err := server.Connect() + if err != nil { + return err + } + + contentTypeArg := mail.TextPlain + if contentType == "text/html" { + contentTypeArg = mail.TextHTML + } + + email := mail.NewMSG() + email.SetFrom(m.config.From). + AddTo(receiver). + SetSubject(subject). + SetBody(contentTypeArg, body) + + if email.Error != nil { + return err + } + + if err := email.Send(smtpClient); err != nil { + return err + } + + return smtpClient.Quit() +}