From 08c9e8c8ad2b0088c7413e4a7ffbe685717209e1 Mon Sep 17 00:00:00 2001 From: Tom Brooks <100007843+OddTomBrooks@users.noreply.github.com> Date: Mon, 24 Jul 2023 02:33:04 -0400 Subject: [PATCH] [EASI-2713] Email Notification: Model Plan Dates Changed (#609) * feat: add ams model id and other fields to general characteristics * chore: migrated model abbreviation to model plan * chore: add role to plan discussion, begin updating store queries * feat: updated sql to include role * chore: updated user_role naming to be consistent, fix: most recent discussion role endpoint * chore: updated postman collection * chore: cleaned up old migration * chore: removed testing code * chore: removed old ghost code from branch-off * chore: go mod tidy * fix: updated inputs to fix broken tests * fix: added user role to plan discussion test inputs * chore: updated postman collection * feat: added user role description to plan discussion with constraints and unit tests * wip: email notifications when model plan dates change * fix: reverted file to main version * chore: removed debug print code * chore: fix sql constraints, remove trigger * wip: bug fixes and refactoring * fix: added userRole and userRoleDiscussion to discussion replies, improved constraints * fix: server tests relating to discussion reply create input * chore: simplified email recipients table, added email recipient seeding * chore: fixed broken unit test * fix: resolved broken unit test * chore: simplified some unnecessary code * Merge branch 'EASI-2713/email_notif_on_model_plan_dates_changed' of github.com:CMSgov/mint-app into EASI-2713/email_notif_on_model_plan_dates_changed # Please enter a commit message to explain why this merge is necessary, # especially if it merges an updated upstream into a topic branch. # # Lines starting with '#' will be ignored, and an empty message aborts # the commit. * chore: cleaned up migrations from merging features * chore: migrated date changed recipient emails to envrc as we don't have a pipeline for loading configs before dbseed runs which leads to unpredictable first-run behavior on the server * chore: removed ghost code * chore: added checkDateFieldChanged documentation * wip: design changes to simplify model plan date changes email * fix: composing date ranges into common key structs * chore: several major bugs resolved regarding email date changes * chore: updated unit tests * chore: removed debug print and updated gomod * chore: removed gomod change * chore: simplified template and removed unnecessary data structure simplifying conversion code * chore: restructured code and removed debug print statements * fix: date change evaluation bugs * chore: removed print statements, added test coverage TODOs * chore: added test coverage to plan_basics_helper * Added 'copytime' to non-range fields to prevent pointer updates from causing issues --------- Co-authored-by: Clay Benson --- .envrc | 1 + cmd/dbseed/main.go | 60 +-- cmd/dbseed/resolver_wrappers.go | 19 +- docker-compose.yml | 1 + pkg/appconfig/config.go | 4 + pkg/email/address_book.go | 4 + pkg/email/model_plan_date_changed.go | 25 ++ pkg/email/template_service_impl.go | 14 + .../model_plan_date_changed_body.html | 131 +++++++ .../model_plan_date_changed_subject.html | 1 + pkg/graph/resolvers/plan_basics.go | 140 ++++++- pkg/graph/resolvers/plan_basics_helper.go | 259 +++++++++++++ .../resolvers/plan_basics_helper_test.go | 351 ++++++++++++++++++ pkg/graph/resolvers/plan_basics_test.go | 13 +- .../resolvers/prepare_for_clearance_test.go | 47 ++- pkg/graph/schema.resolvers.go | 21 +- pkg/server/routes.go | 7 +- pkg/worker/analyzed_audit_job_test.go | 13 +- 18 files changed, 1060 insertions(+), 51 deletions(-) create mode 100644 pkg/email/model_plan_date_changed.go create mode 100644 pkg/email/templates/model_plan_date_changed_body.html create mode 100644 pkg/email/templates/model_plan_date_changed_subject.html create mode 100644 pkg/graph/resolvers/plan_basics_helper.go create mode 100644 pkg/graph/resolvers/plan_basics_helper_test.go diff --git a/.envrc b/.envrc index 43b9da32eb..2d277ec570 100644 --- a/.envrc +++ b/.envrc @@ -30,6 +30,7 @@ export EMAIL_TEMPLATE_DIR=$APP_DIR/pkg/email/templates export GRT_EMAIL=success@simulator.amazonses.com export ACCESSIBILITY_TEAM_EMAIL=success@simulator.amazonses.com export MINT_TEAM_EMAIL=MINTTeam@cms.hhs.gov +export DATE_CHANGED_RECIPIENT_EMAILS=one@test.gov,two@test.gov # AWS variables export AWS_REGION=us-west-2 diff --git a/cmd/dbseed/main.go b/cmd/dbseed/main.go index bc2d9ec547..e7b03820ee 100644 --- a/cmd/dbseed/main.go +++ b/cmd/dbseed/main.go @@ -111,17 +111,23 @@ func (s *Seeder) SeedData() { // Seed a plan with some information already in it planWithBasics := s.createModelPlan("Plan with Basics", "MINT") - s.updatePlanBasics(planWithBasics, map[string]interface{}{ - "modelType": models.MTVoluntary, - "goal": "Some goal", - "cmsCenters": []string{"CMMI", "OTHER"}, - "cmsOther": "SOME OTHER CMS CENTER", - "cmmiGroups": []string{"PATIENT_CARE_MODELS_GROUP", "SEAMLESS_CARE_MODELS_GROUP"}, - "completeICIP": "2020-05-13T20:47:50.12Z", - "phasedIn": true, - "clearanceStarts": time.Now(), - "highLevelNote": "Some high level note", - }) + s.updatePlanBasics( + nil, + nil, + email.AddressBook{}, + planWithBasics, + map[string]interface{}{ + "modelType": models.MTVoluntary, + "goal": "Some goal", + "cmsCenters": []string{"CMMI", "OTHER"}, + "cmsOther": "SOME OTHER CMS CENTER", + "cmmiGroups": []string{"PATIENT_CARE_MODELS_GROUP", "SEAMLESS_CARE_MODELS_GROUP"}, + "completeICIP": "2020-05-13T20:47:50.12Z", + "phasedIn": true, + "clearanceStarts": time.Now(), + "highLevelNote": "Some high level note", + }, + ) s.existingModelLinkCreate(planWithBasics, []int{links[3].ID, links[4].ID}, nil) // Seed a plan with collaborators @@ -189,19 +195,25 @@ func (s *Seeder) SeedData() { UserName: "BTAL", TeamRole: models.TeamRoleLeadership, }) - s.updatePlanBasics(sampleModelPlan, map[string]interface{}{ - "amsModelID": "123", - "demoCode": "1", - "modelType": models.MTVoluntary, - "goal": "Some goal", - "cmsCenters": []string{"CMMI", "OTHER"}, - "cmsOther": "SOME OTHER CMS CENTER", - "cmmiGroups": []string{"PATIENT_CARE_MODELS_GROUP", "SEAMLESS_CARE_MODELS_GROUP"}, - "completeICIP": "2020-05-13T20:47:50.12Z", - "phasedIn": true, - "clearanceStarts": time.Now(), - "highLevelNote": "Some high level note", - }) + s.updatePlanBasics( + nil, + nil, + email.AddressBook{}, + sampleModelPlan, + map[string]interface{}{ + "amsModelID": "123", + "demoCode": "1", + "modelType": models.MTVoluntary, + "goal": "Some goal", + "cmsCenters": []string{"CMMI", "OTHER"}, + "cmsOther": "SOME OTHER CMS CENTER", + "cmmiGroups": []string{"PATIENT_CARE_MODELS_GROUP", "SEAMLESS_CARE_MODELS_GROUP"}, + "completeICIP": "2020-05-13T20:47:50.12Z", + "phasedIn": true, + "clearanceStarts": time.Now(), + "highLevelNote": "Some high level note", + }, + ) operationalNeeds := s.getOperationalNeedsByModelPlanID(planWithDocuments.ID) if len(operationalNeeds) < 1 { diff --git a/cmd/dbseed/resolver_wrappers.go b/cmd/dbseed/resolver_wrappers.go index 98d0be7d1b..f78fb1be94 100644 --- a/cmd/dbseed/resolver_wrappers.go +++ b/cmd/dbseed/resolver_wrappers.go @@ -59,7 +59,13 @@ func (s *Seeder) updateModelPlan(mp *models.ModelPlan, changes map[string]interf // updatePlanBasics is a wrapper for resolvers.PlanBasicsGetByModelPlanID and resolvers.UpdatePlanBasics // It will panic if an error occurs, rather than bubbling the error up // It will always update the Plan Basics object with the principal value of the Model Plan's "createdBy" -func (s *Seeder) updatePlanBasics(mp *models.ModelPlan, changes map[string]interface{}) *models.PlanBasics { +func (s *Seeder) updatePlanBasics( + emailService oddmail.EmailService, + emailTemplateService email.TemplateService, + addressBook email.AddressBook, + mp *models.ModelPlan, + changes map[string]interface{}, +) *models.PlanBasics { princ := s.getTestPrincipalByUUID(mp.CreatedBy) basics, err := resolvers.PlanBasicsGetByModelPlanIDLOADER(s.Config.Context, mp.ID) @@ -67,7 +73,16 @@ func (s *Seeder) updatePlanBasics(mp *models.ModelPlan, changes map[string]inter panic(err) } - updated, err := resolvers.UpdatePlanBasics(s.Config.Logger, basics.ID, changes, princ, s.Config.Store) + updated, err := resolvers.UpdatePlanBasics( + s.Config.Logger, + basics.ID, + changes, + princ, + s.Config.Store, + emailService, + emailTemplateService, + addressBook, + ) if err != nil { panic(err) } diff --git a/docker-compose.yml b/docker-compose.yml index 10b82fdbcb..b316bfd79c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -60,6 +60,7 @@ services: - EMAIL_HOST=email - EMAIL_PORT=1025 - MINT_TEAM_EMAIL + - DATE_CHANGED_RECIPIENT_EMAILS - EMAIL_SENDER - EMAIL_ENABLED - GRT_EMAIL=success@simulator.amazonses.com diff --git a/pkg/appconfig/config.go b/pkg/appconfig/config.go index 0f32556d62..b65862555b 100644 --- a/pkg/appconfig/config.go +++ b/pkg/appconfig/config.go @@ -139,6 +139,10 @@ const AccessibilityTeamEmailKey = "ACCESSIBILITY_TEAM_EMAIL" // MINTTeamEmailKey is the key for the receiving email for the MINT team const MINTTeamEmailKey = "MINT_TEAM_EMAIL" +// DateChangedRecipientEmailsKey is the key for the receiving email addresses for +// the model plan date changed email notification +const DateChangedRecipientEmailsKey = "DATE_CHANGED_RECIPIENT_EMAILS" + // EmailHostKey is the key for getting the email service's host const EmailHostKey = "EMAIL_HOST" diff --git a/pkg/email/address_book.go b/pkg/email/address_book.go index 1a64a0d1a5..77494ac2bb 100644 --- a/pkg/email/address_book.go +++ b/pkg/email/address_book.go @@ -7,4 +7,8 @@ type AddressBook struct { // MINTTeamEmail is the email address of the MINT team MINTTeamEmail string + + // ModelPlanDateChangedRecipients is the list of email addresses that should + // receive notifications when one or more model plan dates are changed + ModelPlanDateChangedRecipients []string } diff --git a/pkg/email/model_plan_date_changed.go b/pkg/email/model_plan_date_changed.go new file mode 100644 index 0000000000..2c75e4085d --- /dev/null +++ b/pkg/email/model_plan_date_changed.go @@ -0,0 +1,25 @@ +package email + +import "time" + +// ModelPlanDateChangedSubjectContent defines the parameters necessary for the corresponding email subject +type ModelPlanDateChangedSubjectContent struct { + ModelName string +} + +// DateChange defines the parameters necessary for parsing date changes, both singular and ranges +// If the OldRange and NewRange are both nil, then the change is singular +type DateChange struct { + Field string + IsRange bool + OldDate, NewDate *time.Time + OldRangeStart, OldRangeEnd, NewRangeStart, NewRangeEnd *time.Time +} + +// ModelPlanDateChangedBodyContent defines the parameters necessary for the corresponding email body +type ModelPlanDateChangedBodyContent struct { + ClientAddress string + ModelName string + ModelID string + DateChanges []DateChange +} diff --git a/pkg/email/template_service_impl.go b/pkg/email/template_service_impl.go index 64581d3d53..a57042cf88 100644 --- a/pkg/email/template_service_impl.go +++ b/pkg/email/template_service_impl.go @@ -52,6 +52,15 @@ var planDiscussionCreatedSubjectTemplate string //go:embed templates/plan_discussion_created_body.html var planDiscussionCreatedBodyTemplate string +// ModelPlanDateChangedTemplateName is the template name definition for the corresponding email template +const ModelPlanDateChangedTemplateName string = "model_plan_date_changed" + +//go:embed templates/model_plan_date_changed_subject.html +var modelPlanDateChangedSubjectTemplate string + +//go:embed templates/model_plan_date_changed_body.html +var modelPlanDateChangedBodyTemplate string + // TemplateServiceImpl is an implementation-specific structure loading all resources necessary for server execution type TemplateServiceImpl struct { templateCache *emailTemplates.TemplateCache @@ -99,6 +108,11 @@ func (t *TemplateServiceImpl) Load() error { return err } + err = t.loadEmailTemplate(ModelPlanDateChangedTemplateName, modelPlanDateChangedSubjectTemplate, modelPlanDateChangedBodyTemplate) + if err != nil { + return err + } + return nil } diff --git a/pkg/email/templates/model_plan_date_changed_body.html b/pkg/email/templates/model_plan_date_changed_body.html new file mode 100644 index 0000000000..dc329732c6 --- /dev/null +++ b/pkg/email/templates/model_plan_date_changed_body.html @@ -0,0 +1,131 @@ + + + + + Content + + + +

MINT

+

The Model Innovation Tool

+
+

Dates updated for {{.ModelName}}

+
+
+

Anticipated high level timeline

+
+ +{{define "oldDate"}} + {{if .}} + {{.Format "01/02/2006"}} + {{else}} + no date entered + {{end}} +{{end}} + +{{define "newDate"}} + {{if .}} + {{.Format "01/02/2006"}} + {{else}} + no date entered + {{end}} +{{end}} + +{{range .DateChanges}} +

+ {{.Field}}: +
+ {{if .IsRange}} + + {{template "oldDate" .OldRangeStart}} - {{template "oldDate" .OldRangeEnd}}
+
+ {{template "newDate" .NewRangeStart}} - {{template "newDate" .NewRangeEnd}} + {{else}} + {{template "oldDate" .OldDate}}
+ {{template "newDate" .NewDate}} + {{end}} +
+
+

+{{end}} + +
+

+ + View this Model Plan in MINT + +

+ + \ No newline at end of file diff --git a/pkg/email/templates/model_plan_date_changed_subject.html b/pkg/email/templates/model_plan_date_changed_subject.html new file mode 100644 index 0000000000..12348a6ff3 --- /dev/null +++ b/pkg/email/templates/model_plan_date_changed_subject.html @@ -0,0 +1 @@ +Dates updated for {{.ModelName}} \ No newline at end of file diff --git a/pkg/graph/resolvers/plan_basics.go b/pkg/graph/resolvers/plan_basics.go index 3e1e327b54..15fffd026b 100644 --- a/pkg/graph/resolvers/plan_basics.go +++ b/pkg/graph/resolvers/plan_basics.go @@ -3,6 +3,9 @@ package resolvers import ( "context" + "github.com/cmsgov/mint-app/pkg/email" + "github.com/cmsgov/mint-app/pkg/shared/oddmail" + "github.com/google/uuid" "go.uber.org/zap" @@ -13,13 +16,47 @@ import ( ) // UpdatePlanBasics implements resolver logic to update a plan basics object -func UpdatePlanBasics(logger *zap.Logger, id uuid.UUID, changes map[string]interface{}, principal authentication.Principal, store *storage.Store) (*models.PlanBasics, error) { +func UpdatePlanBasics( + logger *zap.Logger, + id uuid.UUID, + changes map[string]interface{}, + principal authentication.Principal, + store *storage.Store, + emailService oddmail.EmailService, + emailTemplateService email.TemplateService, + addressBook email.AddressBook, +) (*models.PlanBasics, error) { // Get existing basics existing, err := store.PlanBasicsGetByID(logger, id) if err != nil { return nil, err } + modelPlan, err := store.ModelPlanGetByID(logger, existing.ModelPlanID) + if err != nil { + return nil, err + } + + if emailService != nil && + emailTemplateService != nil && + len(addressBook.ModelPlanDateChangedRecipients) > 0 { + err2 := processChangedDates( + logger, + changes, + existing, + emailService, + emailTemplateService, + addressBook, + modelPlan, + ) + if err2 != nil { + logger.Info("Failed to process changed dates", + zap.String("modelPlanID", modelPlan.ID.String()), + zap.Error(err2), + ) + } + } + err = BaseTaskListSectionPreUpdate(logger, existing, changes, principal, store) if err != nil { return nil, err @@ -29,6 +66,107 @@ func UpdatePlanBasics(logger *zap.Logger, id uuid.UUID, changes map[string]inter return retBasics, err } +func processChangedDates( + logger *zap.Logger, + changes map[string]interface{}, + existing *models.PlanBasics, + emailService oddmail.EmailService, + emailTemplateService email.TemplateService, + addressBook email.AddressBook, + modelPlan *models.ModelPlan, +) error { + dateChanges, err := extractChangedDates(changes, existing) + if err != nil { + return err + } + + if len(dateChanges) > 0 { + go func() { + err2 := sendDateChangedEmails( + emailService, + emailTemplateService, + addressBook, + modelPlan, + dateChanges, + ) + + if err2 != nil { + logger.Error("Failed to send email notification", + zap.Error(err), + zap.String("modelPlanID", modelPlan.ID.String()), + ) + } + }() + } + return nil +} + +func extractChangedDates(changes map[string]interface{}, existing *models.PlanBasics) ( + map[string]email.DateChange, + error, +) { + dp, err := NewDateProcessor(changes, existing) + if err != nil { + return nil, err + } + + dateChanges, err := dp.ExtractChangedDates() + if err != nil { + return nil, err + } + + return dateChanges, nil +} + +func sendDateChangedEmails( + emailService oddmail.EmailService, + emailTemplateService email.TemplateService, + addressBook email.AddressBook, + modelPlan *models.ModelPlan, + dateChanges map[string]email.DateChange, +) error { + emailTemplate, err := emailTemplateService.GetEmailTemplate(email.ModelPlanDateChangedTemplateName) + if err != nil { + return err + } + + emailSubject, err := emailTemplate.GetExecutedSubject(email.ModelPlanDateChangedSubjectContent{ + ModelName: modelPlan.ModelName, + }) + if err != nil { + return err + } + + dateChangeSlice := make([]email.DateChange, 0, len(dateChanges)) + for _, v := range dateChanges { + dateChangeSlice = append(dateChangeSlice, v) + } + + emailBody, err := emailTemplate.GetExecutedBody(email.ModelPlanDateChangedBodyContent{ + ClientAddress: emailService.GetConfig().GetClientAddress(), + ModelName: modelPlan.ModelName, + ModelID: modelPlan.GetModelPlanID().String(), + DateChanges: dateChangeSlice, + }) + if err != nil { + return err + } + + err = emailService.Send( + addressBook.DefaultSender, + addressBook.ModelPlanDateChangedRecipients, + nil, + emailSubject, + "text/html", + emailBody, + ) + if err != nil { + return err + } + + return nil +} + // PlanBasicsGetByModelPlanIDLOADER implements resolver logic to get plan basics by a model plan ID using a data loader func PlanBasicsGetByModelPlanIDLOADER(ctx context.Context, modelPlanID uuid.UUID) (*models.PlanBasics, error) { allLoaders := loaders.Loaders(ctx) diff --git a/pkg/graph/resolvers/plan_basics_helper.go b/pkg/graph/resolvers/plan_basics_helper.go new file mode 100644 index 0000000000..975596da4d --- /dev/null +++ b/pkg/graph/resolvers/plan_basics_helper.go @@ -0,0 +1,259 @@ +package resolvers + +import ( + "fmt" + "time" + + "github.com/cmsgov/mint-app/pkg/email" + + "github.com/mitchellh/mapstructure" + + "github.com/cmsgov/mint-app/pkg/models" +) + +type dateFieldData struct { + IsRange bool + IsRangeStart bool + OtherRangeKey string + CommonKey string // Used to tether data between two ends of a date range, nil or empty for single dates + HumanReadableName string +} + +// DateProcessor is a struct that processes date changes +type DateProcessor struct { + changes map[string]interface{} + existing map[string]interface{} + FieldDataMap map[string]dateFieldData +} + +// NewDateProcessor is a constructor for DateProcessor +func NewDateProcessor(changes map[string]interface{}, existing *models.PlanBasics) (*DateProcessor, error) { + var existingMap map[string]interface{} + decoderConfig := &mapstructure.DecoderConfig{ + Result: &existingMap, + TagName: "json", + WeaklyTypedInput: true, + } + + decoder, err := mapstructure.NewDecoder(decoderConfig) + if err != nil { + return nil, err + } + + err = decoder.Decode(existing) + if err != nil { + fmt.Printf("Decode error: %v\n", err) + return nil, err + } + + return &DateProcessor{changes: changes, existing: existingMap}, nil +} + +func copyTime(t *time.Time) *time.Time { + if t != nil { + copyData := new(time.Time) + *copyData = *t + return copyData + } + return nil +} + +// ExtractChangedDates extracts the changed dates from the DateProcessor +func (dp *DateProcessor) ExtractChangedDates() (map[string]email.DateChange, error) { + fieldDataMap := getFieldDataMap() + + dateChanges := make(map[string]email.DateChange) + + for fieldKey, fieldData := range fieldDataMap { + + isFieldChanged, oldValue, newValue := dp.checkDateFieldChanged(fieldKey) + if isFieldChanged { + if fieldData.IsRange { // check if the field is a range + if _, foundExistingDCV := dateChanges[fieldData.CommonKey]; foundExistingDCV { + continue + } + + dateChangeValue := &email.DateChange{ + Field: fieldData.HumanReadableName, + IsRange: true, + } + + // Determine the values for the other end of the range + isOtherFieldChanged, otherOldValue, otherNewValue := dp.checkDateFieldChanged(fieldData.OtherRangeKey) + if !isOtherFieldChanged { + existingData, existingDataFound := dp.existing[fieldData.OtherRangeKey] + otherNewData, newFound := dp.changes[fieldData.OtherRangeKey] + + if existingDataFound { + otherOldValue = existingData.(*time.Time) + if newFound { + if otherNewData != nil { + otherNewValueParsed, err := time.Parse(time.RFC3339, otherNewData.(string)) + otherNewValue = &otherNewValueParsed + if err != nil { + return nil, err + } + + if otherNewValue.IsZero() { + otherNewValue = nil + } + } + } else { + otherNewValue = otherOldValue + } + } else { + otherOldValue = nil + otherNewValue = nil + } + } + + if fieldData.IsRangeStart { + dateChangeValue.OldRangeStart = copyTime(oldValue) + dateChangeValue.NewRangeStart = copyTime(newValue) + dateChangeValue.OldRangeEnd = copyTime(otherOldValue) + dateChangeValue.NewRangeEnd = copyTime(otherNewValue) + } else { + dateChangeValue.OldRangeEnd = copyTime(oldValue) + dateChangeValue.NewRangeEnd = copyTime(newValue) + dateChangeValue.OldRangeStart = copyTime(otherOldValue) + dateChangeValue.NewRangeStart = copyTime(otherNewValue) + } + + if fieldData.CommonKey == "" { + return nil, fmt.Errorf("CommonKey cannot be empty for range fields") + } + + dateChanges[fieldData.CommonKey] = *dateChangeValue + } else { + dateChanges[fieldKey] = email.DateChange{ + Field: fieldData.HumanReadableName, + IsRange: false, + OldDate: copyTime(oldValue), + NewDate: copyTime(newValue), + } + } + } + } + + return dateChanges, nil +} + +// Returns: +// 1) Boolean: true if the field has changed +// 2) *time.Time: Old value of the field converted to a pointer to a time.Time +// 3) *time.Time: New value of the field converted to a pointer to a time.Time +func (dp *DateProcessor) checkDateFieldChanged(field string) ( + bool, + *time.Time, + *time.Time, +) { + newVal, newExists := dp.changes[field] + oldVal, oldExists := dp.existing[field] + var newTimeVal, oldTimeVal *time.Time + hasAssignedNewValue := false + + // If we are assigning a new value then we have a change + if newExists { + hasAssignedNewValue = true + + if newVal != nil { + switch v := newVal.(type) { + case string: + newTimeParsed, err := time.Parse(time.RFC3339, v) + if err != nil { + return false, nil, nil + } + + if !newTimeParsed.IsZero() { + newTimeVal = &newTimeParsed + } + default: + return false, nil, nil + } + } + } + + var ok bool + if oldExists && oldVal != nil { + oldTimeVal, ok = oldVal.(*time.Time) + if !ok || (oldTimeVal != nil && oldTimeVal.IsZero()) { + oldTimeVal = nil + } + } + + // If we have not assigned a new value then we should default to using the old value + if !hasAssignedNewValue { + return false, oldTimeVal, oldTimeVal + } + + bothTimesAreNil := oldTimeVal == nil && newTimeVal == nil + neitherTimeIsNil := oldTimeVal != nil && newTimeVal != nil + + // If we are assigning the same value as that which already exists we should not + if bothTimesAreNil || (neitherTimeIsNil && (*newTimeVal).Equal(*oldTimeVal)) { + return false, oldTimeVal, oldTimeVal + } + + return true, oldTimeVal, newTimeVal +} + +// TODO: How can this be simplified using struct tags? +func getFieldDataMap() map[string]dateFieldData { + fieldData := map[string]dateFieldData{ + "completeICIP": { + HumanReadableName: "Complete ICIP", + IsRange: false, + }, + "clearanceStarts": { + HumanReadableName: "Clearance", + IsRange: true, + IsRangeStart: true, + OtherRangeKey: "clearanceEnds", + CommonKey: "clearance", + }, + "clearanceEnds": { + HumanReadableName: "Clearance", + IsRange: true, + IsRangeStart: false, + OtherRangeKey: "clearanceStarts", + CommonKey: "clearance", + }, + "announced": { + HumanReadableName: "Announce model", + IsRange: false, + }, + "applicationsStart": { + HumanReadableName: "Application period", + IsRange: true, + IsRangeStart: true, + OtherRangeKey: "applicationsEnd", + CommonKey: "applications", + }, + "applicationsEnd": { + HumanReadableName: "Application period", + IsRange: true, + IsRangeStart: false, + OtherRangeKey: "applicationsStart", + CommonKey: "applications", + }, + "performancePeriodStarts": { + HumanReadableName: "Performance period", + IsRange: true, + IsRangeStart: true, + OtherRangeKey: "performancePeriodEnds", + CommonKey: "performancePeriod", + }, + "performancePeriodEnds": { + HumanReadableName: "Performance period", + IsRange: true, + IsRangeStart: false, + OtherRangeKey: "performancePeriodStarts", + CommonKey: "performancePeriod", + }, + "wrapUpEnds": { + HumanReadableName: "Model wrap-up end date", + IsRange: false, + }, + } + return fieldData +} diff --git a/pkg/graph/resolvers/plan_basics_helper_test.go b/pkg/graph/resolvers/plan_basics_helper_test.go new file mode 100644 index 0000000000..5a84637d9d --- /dev/null +++ b/pkg/graph/resolvers/plan_basics_helper_test.go @@ -0,0 +1,351 @@ +package resolvers + +import ( + "time" + + "github.com/cmsgov/mint-app/pkg/email" + + "github.com/cmsgov/mint-app/pkg/models" +) + +func (suite *ResolverSuite) TestDateProcessorExtractChangedDates() { + + // Set up some test times + t1, _ := time.Parse(time.RFC3339, "2024-01-01T00:00:00Z") + t2, _ := time.Parse(time.RFC3339, "2024-02-02T00:00:00Z") + + // Define a default existing PlanBasics object + defaultExisting := &models.PlanBasics{ + CompleteICIP: &t1, + ClearanceStarts: &t1, + ClearanceEnds: &t1, + Announced: &t1, + ApplicationsStart: &t1, + ApplicationsEnd: &t1, + PerformancePeriodStarts: &t1, + PerformancePeriodEnds: &t1, + WrapUpEnds: &t1, + } + + testCases := []struct { + name string + changes map[string]interface{} + existing *models.PlanBasics + expected map[string]email.DateChange + }{ + // No fields changed + { + name: "No fields changed", + changes: map[string]interface{}{}, + existing: defaultExisting, + expected: map[string]email.DateChange{}, + }, + // Single field changed + { + name: "Single field changed", + changes: map[string]interface{}{ + "performancePeriodStarts": t2.Format(time.RFC3339), + }, + existing: defaultExisting, + expected: map[string]email.DateChange{ + "performancePeriod": { + Field: "Performance period", + IsRange: true, + OldDate: nil, + NewDate: nil, + OldRangeStart: defaultExisting.PerformancePeriodStarts, + OldRangeEnd: defaultExisting.PerformancePeriodEnds, + NewRangeStart: &t2, + NewRangeEnd: defaultExisting.PerformancePeriodEnds, + }, + }, + }, + // Incorrect field name + { + name: "Incorrect field name", + changes: map[string]interface{}{ + "wrongField": t2.Format(time.RFC3339), + }, + existing: defaultExisting, + expected: map[string]email.DateChange{}, + }, + // All fields changed + { + name: "All fields changed", + changes: map[string]interface{}{ + "completeICIP": t2.Format(time.RFC3339), + "clearanceStarts": t2.Format(time.RFC3339), + "clearanceEnds": t2.Format(time.RFC3339), + "announced": t2.Format(time.RFC3339), + "applicationsStart": t2.Format(time.RFC3339), + "applicationsEnd": t2.Format(time.RFC3339), + "performancePeriodStarts": t2.Format(time.RFC3339), + "performancePeriodEnds": t2.Format(time.RFC3339), + "wrapUpEnds": t2.Format(time.RFC3339), + }, + existing: defaultExisting, + expected: map[string]email.DateChange{ + "completeICIP": { + Field: "Complete ICIP", + IsRange: false, + OldDate: defaultExisting.CompleteICIP, + NewDate: &t2, + OldRangeStart: nil, + OldRangeEnd: nil, + NewRangeStart: nil, + NewRangeEnd: nil, + }, + "clearance": { + Field: "Clearance", + IsRange: true, + OldDate: nil, + NewDate: nil, + OldRangeStart: defaultExisting.ClearanceStarts, + OldRangeEnd: defaultExisting.ClearanceEnds, + NewRangeStart: &t2, + NewRangeEnd: &t2, + }, + "announced": { + Field: "Announce model", + IsRange: false, + OldDate: defaultExisting.Announced, + NewDate: &t2, + OldRangeStart: nil, + OldRangeEnd: nil, + NewRangeStart: nil, + NewRangeEnd: nil, + }, + "applications": { + Field: "Application period", + IsRange: true, + OldDate: nil, + NewDate: nil, + OldRangeStart: defaultExisting.ApplicationsStart, + OldRangeEnd: defaultExisting.ApplicationsEnd, + NewRangeStart: &t2, + NewRangeEnd: &t2, + }, + "performancePeriod": { + Field: "Performance period", + IsRange: true, + OldDate: nil, + NewDate: nil, + OldRangeStart: defaultExisting.PerformancePeriodStarts, + OldRangeEnd: defaultExisting.PerformancePeriodEnds, + NewRangeStart: &t2, + NewRangeEnd: &t2, + }, + "wrapUpEnds": { + Field: "Model wrap-up end date", + IsRange: false, + OldDate: defaultExisting.WrapUpEnds, + NewDate: &t2, + OldRangeStart: nil, + OldRangeEnd: nil, + NewRangeStart: nil, + NewRangeEnd: nil, + }, + }, + }, + // Field was nil initially + { + name: "Field was nil initially", + changes: map[string]interface{}{ + "wrapUpEnds": t1.Format(time.RFC3339), + }, + existing: &models.PlanBasics{ + CompleteICIP: &t1, + ClearanceStarts: &t1, + ClearanceEnds: &t1, + Announced: &t1, + ApplicationsStart: &t1, + ApplicationsEnd: &t1, + PerformancePeriodStarts: &t1, + PerformancePeriodEnds: &t1, + WrapUpEnds: nil, + }, + expected: map[string]email.DateChange{ + "wrapUpEnds": { + Field: "Model wrap-up end date", + IsRange: false, + OldDate: nil, + NewDate: &t1, + OldRangeStart: nil, + OldRangeEnd: nil, + NewRangeStart: nil, + NewRangeEnd: nil, + }, + }, + }, + // Single date field from nil to non-nil + { + name: "Single date field from nil to non-nil", + changes: map[string]interface{}{ + "announced": t2.Format(time.RFC3339), + }, + existing: &models.PlanBasics{}, + expected: map[string]email.DateChange{ + "announced": { + Field: "Announce model", + IsRange: false, + OldDate: nil, + NewDate: &t2, + OldRangeStart: nil, + OldRangeEnd: nil, + NewRangeStart: nil, + NewRangeEnd: nil, + }, + }, + }, + // Single date field from non-nil to nil + { + name: "Single date field from non-nil to nil", + changes: map[string]interface{}{ + "announced": nil, + }, + existing: defaultExisting, + expected: map[string]email.DateChange{ + "announced": { + Field: "Announce model", + IsRange: false, + OldDate: &t1, + NewDate: nil, + OldRangeStart: nil, + OldRangeEnd: nil, + NewRangeStart: nil, + NewRangeEnd: nil, + }, + }, + }, + // Range begin date from nil to non-nil + { + name: "Range begin date from nil to non-nil", + changes: map[string]interface{}{ + "applicationsStart": t2.Format(time.RFC3339), + }, + existing: &models.PlanBasics{}, + expected: map[string]email.DateChange{ + "applications": { + Field: "Application period", + IsRange: true, + OldDate: nil, + NewDate: nil, + OldRangeStart: nil, + OldRangeEnd: nil, + NewRangeStart: &t2, + NewRangeEnd: nil, + }, + }, + }, + // Range end date from nil to non-nil + { + name: "Range end date from nil to non-nil", + changes: map[string]interface{}{ + "applicationsEnd": t2.Format(time.RFC3339), + }, + existing: &models.PlanBasics{}, + expected: map[string]email.DateChange{ + "applications": { + Field: "Application period", + IsRange: true, + OldDate: nil, + NewDate: nil, + OldRangeStart: nil, + OldRangeEnd: nil, + NewRangeStart: nil, + NewRangeEnd: &t2, + }, + }, + }, + // Range begin date from non-nil to nil + { + name: "Range begin date from non-nil to nil", + changes: map[string]interface{}{ + "applicationsStart": nil, + }, + existing: defaultExisting, + expected: map[string]email.DateChange{ + "applications": { + Field: "Application period", + IsRange: true, + OldDate: nil, + NewDate: nil, + OldRangeStart: &t1, + OldRangeEnd: &t1, + NewRangeStart: nil, + NewRangeEnd: &t1, + }, + }, + }, + // Range end date from non-nil to nil + { + name: "Range end date from non-nil to nil", + changes: map[string]interface{}{ + "applicationsEnd": nil, + }, + existing: defaultExisting, + expected: map[string]email.DateChange{ + "applications": { + Field: "Application period", + IsRange: true, + OldDate: nil, + NewDate: nil, + OldRangeStart: &t1, + OldRangeEnd: &t1, + NewRangeStart: &t1, + NewRangeEnd: nil, + }, + }, + }, + // Range both dates from nil to non-nil + { + name: "Range both dates from nil to non-nil", + changes: map[string]interface{}{ + "applicationsStart": t2.Format(time.RFC3339), + "applicationsEnd": t2.Format(time.RFC3339), + }, + existing: &models.PlanBasics{}, + expected: map[string]email.DateChange{ + "applications": { + Field: "Application period", + IsRange: true, + OldDate: nil, + NewDate: nil, + OldRangeStart: nil, + OldRangeEnd: nil, + NewRangeStart: &t2, + NewRangeEnd: &t2, + }, + }, + }, + // Range both dates from non-nil to nil + { + name: "Range both dates from non-nil to nil", + changes: map[string]interface{}{ + "applicationsStart": nil, + "applicationsEnd": nil, + }, + existing: defaultExisting, + expected: map[string]email.DateChange{ + "applications": { + Field: "Application period", + IsRange: true, + OldDate: nil, + NewDate: nil, + OldRangeStart: &t1, + OldRangeEnd: &t1, + NewRangeStart: nil, + NewRangeEnd: nil, + }, + }, + }, + } + + for _, tc := range testCases { + suite.Run(tc.name, func() { + dp, _ := NewDateProcessor(tc.changes, tc.existing) + changes, _ := dp.ExtractChangedDates() + suite.Equal(tc.expected, changes) + }) + } +} diff --git a/pkg/graph/resolvers/plan_basics_test.go b/pkg/graph/resolvers/plan_basics_test.go index 19f5e36a30..3363acd5ce 100644 --- a/pkg/graph/resolvers/plan_basics_test.go +++ b/pkg/graph/resolvers/plan_basics_test.go @@ -5,6 +5,8 @@ import ( "fmt" "time" + "github.com/cmsgov/mint-app/pkg/email" + "github.com/google/uuid" "golang.org/x/sync/errgroup" @@ -96,7 +98,16 @@ func (suite *ResolverSuite) TestUpdatePlanBasics() { "highLevelNote": "Some high level note", } - updatedBasics, err := UpdatePlanBasics(suite.testConfigs.Logger, basics.ID, changes, suite.testConfigs.Principal, suite.testConfigs.Store) + updatedBasics, err := UpdatePlanBasics( + suite.testConfigs.Logger, + basics.ID, + changes, + suite.testConfigs.Principal, + suite.testConfigs.Store, + nil, + nil, + email.AddressBook{}, + ) suite.NoError(err) suite.EqualValues(suite.testConfigs.Principal.Account().ID, *updatedBasics.ModifiedBy) diff --git a/pkg/graph/resolvers/prepare_for_clearance_test.go b/pkg/graph/resolvers/prepare_for_clearance_test.go index 9bd1d93877..0f9049d9ee 100644 --- a/pkg/graph/resolvers/prepare_for_clearance_test.go +++ b/pkg/graph/resolvers/prepare_for_clearance_test.go @@ -3,6 +3,8 @@ package resolvers import ( "time" + "github.com/cmsgov/mint-app/pkg/email" + "github.com/cmsgov/mint-app/pkg/graph/model" "github.com/cmsgov/mint-app/pkg/models" ) @@ -40,9 +42,18 @@ func (suite *ResolverSuite) TestReadyForClearanceRead() { // Update the basics to have a clearance date set that's too far out (more than 20 days) basics, err := PlanBasicsGetByModelPlanIDLOADER(suite.testConfigs.Context, plan.ID) suite.NoError(err) - _, err = UpdatePlanBasics(suite.testConfigs.Logger, basics.ID, map[string]interface{}{ - "clearanceStarts": time.Now().Add(time.Hour * 24 * 21).Format(time.RFC3339), // 21 days from now - }, suite.testConfigs.Principal, suite.testConfigs.Store) + _, err = UpdatePlanBasics( + suite.testConfigs.Logger, + basics.ID, + map[string]interface{}{ + "clearanceStarts": time.Now().Add(time.Hour * 24 * 21).Format(time.RFC3339), // 21 days from now + }, + suite.testConfigs.Principal, + suite.testConfigs.Store, + nil, + nil, + email.AddressBook{}, + ) suite.NoError(err) // Clearance start date is 21 days out, still shouldn't be ready to start planClearance, err = ReadyForClearanceRead(suite.testConfigs.Logger, suite.testConfigs.Store, plan.ID) @@ -53,9 +64,18 @@ func (suite *ResolverSuite) TestReadyForClearanceRead() { // Update the basics to have a clearance date set that's within the date range (15 days) basics, err = PlanBasicsGetByModelPlanIDLOADER(suite.testConfigs.Context, plan.ID) suite.NoError(err) - _, err = UpdatePlanBasics(suite.testConfigs.Logger, basics.ID, map[string]interface{}{ - "clearanceStarts": time.Now().Add(time.Hour * 24 * 15).Format(time.RFC3339), // 15 days from now - }, suite.testConfigs.Principal, suite.testConfigs.Store) + _, err = UpdatePlanBasics( + suite.testConfigs.Logger, + basics.ID, + map[string]interface{}{ + "clearanceStarts": time.Now().Add(time.Hour * 24 * 15).Format(time.RFC3339), // 15 days from now + }, + suite.testConfigs.Principal, + suite.testConfigs.Store, + nil, + nil, + email.AddressBook{}, + ) suite.NoError(err) // Clearance start date is 15 days out - should be "Ready to Start" planClearance, err = ReadyForClearanceRead(suite.testConfigs.Logger, suite.testConfigs.Store, plan.ID) @@ -66,9 +86,18 @@ func (suite *ResolverSuite) TestReadyForClearanceRead() { // Update the basics to be marked ready for clearance basics, err = PlanBasicsGetByModelPlanIDLOADER(suite.testConfigs.Context, plan.ID) suite.NoError(err) - _, err = UpdatePlanBasics(suite.testConfigs.Logger, basics.ID, map[string]interface{}{ - "status": model.TaskStatusInputReadyForClearance, - }, suite.testConfigs.Principal, suite.testConfigs.Store) + _, err = UpdatePlanBasics( + suite.testConfigs.Logger, + basics.ID, + map[string]interface{}{ + "status": model.TaskStatusInputReadyForClearance, + }, + suite.testConfigs.Principal, + suite.testConfigs.Store, + nil, + nil, + email.AddressBook{}, + ) suite.NoError(err) // Clearance start date is 15 days out & we've started by marking Basics as ready for clearance - should be "In Progress" planClearance, err = ReadyForClearanceRead(suite.testConfigs.Logger, suite.testConfigs.Store, plan.ID) diff --git a/pkg/graph/schema.resolvers.go b/pkg/graph/schema.resolvers.go index 2e773d8c47..74b4ca9373 100644 --- a/pkg/graph/schema.resolvers.go +++ b/pkg/graph/schema.resolvers.go @@ -206,7 +206,16 @@ func (r *mutationResolver) UpdatePlanBasics(ctx context.Context, id uuid.UUID, c principal := appcontext.Principal(ctx) logger := appcontext.ZLogger(ctx) - return resolvers.UpdatePlanBasics(logger, id, changes, principal, r.store) + return resolvers.UpdatePlanBasics( + logger, + id, + changes, + principal, + r.store, + r.emailService, + r.emailTemplateService, + r.addressBook, + ) } // UpdatePlanGeneralCharacteristics is the resolver for the updatePlanGeneralCharacteristics field. @@ -1048,13 +1057,3 @@ type planPaymentsResolver struct{ *Resolver } type possibleOperationalNeedResolver struct{ *Resolver } type queryResolver struct{ *Resolver } type subscriptionResolver struct{ *Resolver } - -// !!! WARNING !!! -// The code below was going to be deleted when updating resolvers. It has been copied here so you have -// one last chance to move it out of harms way if you want. There are two reasons this happens: -// - When renaming or deleting a resolver the old code will be put in here. You can safely delete -// it when you're done. -// - You have helper methods in this file. Move them out to keep these resolver files clean. -func (r *planDiscussionResolver) UserRoleDescription(ctx context.Context, obj *models.PlanDiscussion) (*string, error) { - panic(fmt.Errorf("not implemented: UserRoleDescription - userRoleDescription")) -} diff --git a/pkg/server/routes.go b/pkg/server/routes.go index b2ec470323..bae6c62429 100644 --- a/pkg/server/routes.go +++ b/pkg/server/routes.go @@ -168,9 +168,12 @@ func (s *Server) routes( emailServiceConfig.Port = s.Config.GetInt(appconfig.EmailPortKey) emailServiceConfig.ClientAddress = s.Config.GetString(appconfig.ClientAddressKey) + dateChangedRecipientEmails := strings.Split(s.Config.GetString(appconfig.DateChangedRecipientEmailsKey), ",") + addressBook := email.AddressBook{ - DefaultSender: s.Config.GetString(appconfig.EmailSenderKey), - MINTTeamEmail: s.Config.GetString(appconfig.MINTTeamEmailKey), + DefaultSender: s.Config.GetString(appconfig.EmailSenderKey), + MINTTeamEmail: s.Config.GetString(appconfig.MINTTeamEmailKey), + ModelPlanDateChangedRecipients: dateChangedRecipientEmails, } var emailService *oddmail.GoSimpleMailService diff --git a/pkg/worker/analyzed_audit_job_test.go b/pkg/worker/analyzed_audit_job_test.go index 94999b98b7..29a3df867a 100644 --- a/pkg/worker/analyzed_audit_job_test.go +++ b/pkg/worker/analyzed_audit_job_test.go @@ -4,6 +4,8 @@ import ( "context" "time" + "github.com/cmsgov/mint-app/pkg/email" + faktory "github.com/contribsys/faktory/client" faktory_worker "github.com/contribsys/faktory_worker_go" "github.com/google/uuid" @@ -67,7 +69,16 @@ func (suite *WorkerSuite) TestAnalyzedAuditJob() { clearanceChanges := map[string]interface{}{ "status": "READY_FOR_CLEARANCE", } - _, basicsErr := resolvers.UpdatePlanBasics(worker.Logger, basics.ID, clearanceChanges, suite.testConfigs.Principal, worker.Store) + _, basicsErr := resolvers.UpdatePlanBasics( + worker.Logger, + basics.ID, + clearanceChanges, + suite.testConfigs.Principal, + worker.Store, + nil, + nil, + email.AddressBook{}, + ) suite.NoError(basicsErr) _, charErr := resolvers.UpdatePlanGeneralCharacteristics(worker.Logger, genChar.ID, clearanceChanges, suite.testConfigs.Principal, worker.Store) suite.NoError(charErr)