From 1f216b732e81add069596a90c8056253b9cf6e1f Mon Sep 17 00:00:00 2001 From: Gary Zhao Date: Fri, 29 Mar 2024 10:52:43 -0400 Subject: [PATCH] [EASI-4023] New Discussion Reply Notification (#1024) * [EASI-3944] Notifications - On Discussion Replied Notification (#990) * wip: draft of sending notifications on discussion reply * wip: implementing PR feedback * wip: implement various PR feedback * wip: corrected actor ID to be reply creator ID and added discussion creator ID to meta * fix: corrected notification recipient from actor to discussion creator * feat: testing preview for discussion reply notification * chore: updated postman collection * fix: corrected activity meta base struct type * chore: added comment to helper function * chore: deleted unused generated resolver * fix: revereted incorrectly changed user ID assignment * removed irrelevant comment * chore: added more assertions to unit test * Add new reply type guard * add more conditional renders based on type guard * Add the new discussion reply gql fragment * implementing new discussion reply into the UI * Add a few more queries to the getNotification query * Notification content to show on discussions * add model name to util * move notification settings to its own test * add e2e test --------- Co-authored-by: Tom Brooks <100007843+OddTomBrooks@users.noreply.github.com> --- MINT.postman_collection.json | 11 +- cypress/e2e/notification.spec.js | 99 ++- pkg/graph/generated/generated.go | 779 +++++++++++++++++- pkg/graph/resolvers/activity.resolvers.go | 23 + pkg/graph/resolvers/plan_discussion.go | 48 +- pkg/graph/schema/types/activity.graphql | 13 +- pkg/models/new_discussion_replied_meta.go | 69 ++ pkg/notifications/activity.go | 8 + .../new_discussion_replied_meta.go | 28 + .../new_discussion_replied_meta_test.go | 47 ++ pkg/notifications/notifications_test.go | 16 + .../Notifications/GetNotifications.ts | 11 + src/gql/gen/graphql.ts | 28 +- src/gql/gen/types/GetNotifications.ts | 18 +- .../_components/IndividualNotification.tsx | 24 +- .../Notifications/Home/_components/_utils.tsx | 31 + 16 files changed, 1199 insertions(+), 54 deletions(-) create mode 100644 pkg/models/new_discussion_replied_meta.go create mode 100644 pkg/notifications/new_discussion_replied_meta.go create mode 100644 pkg/notifications/new_discussion_replied_meta_test.go diff --git a/MINT.postman_collection.json b/MINT.postman_collection.json index 9b7a54f489..45d25235bc 100644 --- a/MINT.postman_collection.json +++ b/MINT.postman_collection.json @@ -1,9 +1,9 @@ { "info": { - "_postman_id": "32329f70-986e-430d-8a0b-4509ca71dab1", + "_postman_id": "e2043604-4cd9-443c-9267-db489feb92d8", "name": "MINT", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "20320964" + "_exporter_id": "20435042" }, "item": [ { @@ -2089,7 +2089,8 @@ "// pm.collectionVariables.set(\"operationalSolutionSubtaskID\"+i, subtaskID);", "// }" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -2099,7 +2100,7 @@ "body": { "mode": "graphql", "graphql": { - "query": "query currentUser {\n currentUser {\n notifications {\n numUnreadNotifications\n notifications {\n __typename\n id\n isRead\n inAppSent\n emailSent\n activity {\n activityType\n entityID\n actorID\n metaData {\n __typename\n ... on TaggedInPlanDiscussionActivityMeta{\n version\n type\n modelPlanID\n modelPlan{\n modelName\n } \n discussionID\n content\n }\n ... on TaggedInDiscussionReplyActivityMeta {\n version\n type\n modelPlanID\n modelPlan{\n modelName\n } \n discussionID\n replyID\n content\n }\n ... on DailyDigestCompleteActivityMeta{\n version\n type\n modelPlanIDs\n date\n analyzedAudits{\n id\n modelPlanID\n modelName\n date\n changes{\n modelPlan{\n oldName\n statusChanges\n }\n documents{\n count\n }\n crTdls{\n activity\n }\n planSections{\n updated\n readyForReview\n readyForClearance\n }\n modelLeads{\n added{\n id\n commonName\n # userAccount{\n # id\n # email\n # }\n }\n }\n planDiscussions{\n activity\n }\n }\n\n }\n\n } \n }\n createdByUserAccount {\n commonName\n }\n }\n createdByUserAccount {\n commonName\n }\n }\n }\n }\n}\n", + "query": "query currentUser {\n currentUser {\n notifications {\n numUnreadNotifications\n notifications {\n __typename\n id\n isRead\n inAppSent\n emailSent\n activity {\n activityType\n entityID\n actorID\n metaData {\n __typename\n ... on TaggedInPlanDiscussionActivityMeta{\n version\n type\n modelPlanID\n modelPlan{\n modelName\n } \n discussionID\n content\n }\n ... on TaggedInDiscussionReplyActivityMeta {\n version\n type\n modelPlanID\n modelPlan{\n modelName\n } \n discussionID\n replyID\n content\n }\n ... on NewDiscussionRepliedActivityMeta {\n version\n type\n discussionID\n replyID\n content\n } \n ... on DailyDigestCompleteActivityMeta{\n version\n type\n modelPlanIDs\n date\n analyzedAudits{\n id\n modelPlanID\n modelName\n date\n changes{\n modelPlan{\n oldName\n statusChanges\n }\n documents{\n count\n }\n crTdls{\n activity\n }\n planSections{\n updated\n readyForReview\n readyForClearance\n }\n modelLeads{\n added{\n id\n commonName\n # userAccount{\n # id\n # email\n # }\n }\n }\n planDiscussions{\n activity\n }\n }\n\n }\n\n } \n }\n createdByUserAccount {\n commonName\n }\n }\n createdByUserAccount {\n commonName\n }\n }\n }\n }\n}\n", "variables": "" } }, @@ -2424,4 +2425,4 @@ "value": "" } ] -} \ No newline at end of file +} diff --git a/cypress/e2e/notification.spec.js b/cypress/e2e/notification.spec.js index 8c5ec7212f..c86f7957f4 100644 --- a/cypress/e2e/notification.spec.js +++ b/cypress/e2e/notification.spec.js @@ -48,7 +48,8 @@ describe('Notification Center', () => { cy.contains('button', 'Save discussion').click(); - cy.visit('/notifications'); + cy.get('[data-testid="close-discussions"]').click(); + cy.get('[data-testid="navmenu__notification"]').first().click(); // Actual Notification Test cy.get('[data-testid="navmenu__notification"]') @@ -67,7 +68,9 @@ describe('Notification Center', () => { .find('button', 'View Discussion') .click(); - cy.visit('/notifications'); + // Navigate to Notification page (faster than cy.visit) + cy.get('[data-testid="close-discussions"]').click(); + cy.get('[data-testid="navmenu__notification"]').first().click(); // Check to see first entry should no longer have red dot cy.get('[data-testid="individual-notification"]') @@ -83,6 +86,30 @@ describe('Notification Center', () => { 'exist' ); cy.get('[data-testid="notification-red-dot"]').should('have.length', 0); + }); + + it('navigates to see Daily Digest notification', () => { + cy.localLogin({ name: 'MINT', role: 'MINT_ASSESSMENT_NONPROD' }); + cy.visit('/notifications'); + + cy.get('[data-testid="individual-notification"]') + .first() + .find('[data-testid="notification-red-dot"]') + .should('exist'); + cy.contains('button', 'View digest').click(); + + cy.get('[data-testid="notification--daily-digest"').should('exist'); + + cy.contains('h3', 'Empty Plan').siblings('a').click(); + + cy.location().should(loc => { + expect(loc.pathname).to.match(/models\/.{36}\/read-only\/model-basics/); + }); + }); + + it('navigates to see Notification Settings', () => { + cy.localLogin({ name: 'MINT', role: 'MINT_ASSESSMENT_NONPROD' }); + cy.visit('/notifications'); // Notification Settings Test cy.contains('a', 'Notification settings').click(); @@ -107,22 +134,66 @@ describe('Notification Center', () => { ); }); - it('navigates to see Daily Digest notification', () => { - cy.localLogin({ name: 'MINT', role: 'MINT_ASSESSMENT_NONPROD' }); - cy.visit('/notifications'); + it.only('testing New Discussion Reply Notification', () => { + cy.localLogin({ name: 'JTTC', role: 'MINT_ASSESSMENT_NONPROD' }); + cy.clickPlanTableByName('Empty Plan'); + + // Create a discussion to start things off + cy.contains('button', 'Start a discussion').click(); + + cy.contains('h1', 'Start a discussion'); + + cy.contains('button', 'Save discussion').should('be.disabled'); + + cy.get('#user-role').should('not.be.disabled'); + + cy.get('#user-role').select('None of the above'); + + cy.get('#user-role-description') + .type('Designer') + .should('have.value', 'Designer'); + + cy.get('#mention-editor').type('@ana'); + cy.get('#JTTC').contains('Anabelle Jerde (JTTC)').click(); + cy.get('#mention-editor').type('First Notification'); + cy.get('#mention-editor').should( + 'have.text', + '@Anabelle Jerde (JTTC) First Notification' + ); + + cy.contains('button', 'Save discussion').click(); + + // New Discussion Reply test + cy.contains('button', 'Reply').click(); + + cy.contains('label', 'Type your reply'); + + cy.get('#mention-editor').type( + 'Triggering new discussion reply notification' + ); + + cy.contains('button', 'Save reply').click(); + + cy.get('[data-testid="close-discussions"]').click(); + cy.get('[data-testid="navmenu__notification"]').first().click(); + + cy.get('[data-testid="navmenu__notifications--yesNotification"').should( + 'exist' + ); + + cy.get('[data-testid="individual-notification"]').should('have.length', 2); cy.get('[data-testid="individual-notification"]') .first() - .find('[data-testid="notification-red-dot"]') - .should('exist'); - cy.contains('button', 'View digest').click(); - - cy.get('[data-testid="notification--daily-digest"').should('exist'); + .find('button', 'View Discussion') + .click(); - cy.contains('h3', 'Empty Plan').siblings('a').click(); + cy.get('[data-testid="close-discussions"]').click(); + cy.get('[data-testid="navmenu__notification"]').first().click(); - cy.location().should(loc => { - expect(loc.pathname).to.match(/models\/.{36}\/read-only\/model-basics/); - }); + cy.get('[data-testid="individual-notification"]') + .first() + .find('[data-testid="notification-red-dot"]') + .should('not.exist'); }); }); diff --git a/pkg/graph/generated/generated.go b/pkg/graph/generated/generated.go index 85b034703b..ae0942ac42 100644 --- a/pkg/graph/generated/generated.go +++ b/pkg/graph/generated/generated.go @@ -51,6 +51,7 @@ type ResolverRoot interface { ExistingModelLinks() ExistingModelLinksResolver ModelPlan() ModelPlanResolver Mutation() MutationResolver + NewDiscussionRepliedActivityMeta() NewDiscussionRepliedActivityMetaResolver OperationalNeed() OperationalNeedResolver OperationalSolution() OperationalSolutionResolver PlanBasics() PlanBasicsResolver @@ -332,6 +333,18 @@ type ComplexityRoot struct { AgreedDts func(childComplexity int) int } + NewDiscussionRepliedActivityMeta struct { + Content func(childComplexity int) int + Discussion func(childComplexity int) int + DiscussionID func(childComplexity int) int + ModelPlan func(childComplexity int) int + ModelPlanID func(childComplexity int) int + Reply func(childComplexity int) int + ReplyID func(childComplexity int) int + Type func(childComplexity int) int + Version func(childComplexity int) int + } + OperationalNeed struct { CreatedBy func(childComplexity int) int CreatedByUserAccount func(childComplexity int) int @@ -1251,6 +1264,13 @@ type MutationResolver interface { MarkAllNotificationsAsRead(ctx context.Context) ([]*models.UserNotification, error) UpdateUserNotificationPreferences(ctx context.Context, changes map[string]interface{}) (*models.UserNotificationPreferences, error) } +type NewDiscussionRepliedActivityMetaResolver interface { + ModelPlan(ctx context.Context, obj *models.NewDiscussionRepliedActivityMeta) (*models.ModelPlan, error) + + Discussion(ctx context.Context, obj *models.NewDiscussionRepliedActivityMeta) (*models.PlanDiscussion, error) + + Reply(ctx context.Context, obj *models.NewDiscussionRepliedActivityMeta) (*models.DiscussionReply, error) +} type OperationalNeedResolver interface { Solutions(ctx context.Context, obj *models.OperationalNeed, includeNotNeeded bool) ([]*models.OperationalSolution, error) } @@ -2990,6 +3010,69 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in return e.complexity.NDAInfo.AgreedDts(childComplexity), true + case "NewDiscussionRepliedActivityMeta.content": + if e.complexity.NewDiscussionRepliedActivityMeta.Content == nil { + break + } + + return e.complexity.NewDiscussionRepliedActivityMeta.Content(childComplexity), true + + case "NewDiscussionRepliedActivityMeta.discussion": + if e.complexity.NewDiscussionRepliedActivityMeta.Discussion == nil { + break + } + + return e.complexity.NewDiscussionRepliedActivityMeta.Discussion(childComplexity), true + + case "NewDiscussionRepliedActivityMeta.discussionID": + if e.complexity.NewDiscussionRepliedActivityMeta.DiscussionID == nil { + break + } + + return e.complexity.NewDiscussionRepliedActivityMeta.DiscussionID(childComplexity), true + + case "NewDiscussionRepliedActivityMeta.modelPlan": + if e.complexity.NewDiscussionRepliedActivityMeta.ModelPlan == nil { + break + } + + return e.complexity.NewDiscussionRepliedActivityMeta.ModelPlan(childComplexity), true + + case "NewDiscussionRepliedActivityMeta.modelPlanID": + if e.complexity.NewDiscussionRepliedActivityMeta.ModelPlanID == nil { + break + } + + return e.complexity.NewDiscussionRepliedActivityMeta.ModelPlanID(childComplexity), true + + case "NewDiscussionRepliedActivityMeta.reply": + if e.complexity.NewDiscussionRepliedActivityMeta.Reply == nil { + break + } + + return e.complexity.NewDiscussionRepliedActivityMeta.Reply(childComplexity), true + + case "NewDiscussionRepliedActivityMeta.replyID": + if e.complexity.NewDiscussionRepliedActivityMeta.ReplyID == nil { + break + } + + return e.complexity.NewDiscussionRepliedActivityMeta.ReplyID(childComplexity), true + + case "NewDiscussionRepliedActivityMeta.type": + if e.complexity.NewDiscussionRepliedActivityMeta.Type == nil { + break + } + + return e.complexity.NewDiscussionRepliedActivityMeta.Type(childComplexity), true + + case "NewDiscussionRepliedActivityMeta.version": + if e.complexity.NewDiscussionRepliedActivityMeta.Version == nil { + break + } + + return e.complexity.NewDiscussionRepliedActivityMeta.Version(childComplexity), true + case "OperationalNeed.createdBy": if e.complexity.OperationalNeed.CreatedBy == nil { break @@ -8331,7 +8414,7 @@ enum ActivityType { """ ActivityMetaData is a type that represents all the data that can be captured in an Activity """ -union ActivityMetaData = TaggedInPlanDiscussionActivityMeta | TaggedInDiscussionReplyActivityMeta | DailyDigestCompleteActivityMeta +union ActivityMetaData = TaggedInPlanDiscussionActivityMeta | TaggedInDiscussionReplyActivityMeta | DailyDigestCompleteActivityMeta | NewDiscussionRepliedActivityMeta type TaggedInPlanDiscussionActivityMeta { version: Int! @@ -8355,6 +8438,17 @@ type TaggedInDiscussionReplyActivityMeta { content: String! } +type NewDiscussionRepliedActivityMeta { + version: Int! + type: ActivityType! + modelPlanID: UUID! + modelPlan: ModelPlan! + discussionID: UUID! + discussion: PlanDiscussion! + replyID: UUID! + reply: DiscussionReply! + content: String! +} type DailyDigestCompleteActivityMeta { version: Int! type: ActivityType! @@ -25444,6 +25538,512 @@ func (ec *executionContext) fieldContext_NDAInfo_agreedDts(ctx context.Context, return fc, nil } +func (ec *executionContext) _NewDiscussionRepliedActivityMeta_version(ctx context.Context, field graphql.CollectedField, obj *models.NewDiscussionRepliedActivityMeta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_NewDiscussionRepliedActivityMeta_version(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Version, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(int) + fc.Result = res + return ec.marshalNInt2int(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_NewDiscussionRepliedActivityMeta_version(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "NewDiscussionRepliedActivityMeta", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Int does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _NewDiscussionRepliedActivityMeta_type(ctx context.Context, field graphql.CollectedField, obj *models.NewDiscussionRepliedActivityMeta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_NewDiscussionRepliedActivityMeta_type(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Type, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(models.ActivityType) + fc.Result = res + return ec.marshalNActivityType2githubᚗcomᚋcmsgovᚋmintᚑappᚋpkgᚋmodelsᚐActivityType(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_NewDiscussionRepliedActivityMeta_type(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "NewDiscussionRepliedActivityMeta", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type ActivityType does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _NewDiscussionRepliedActivityMeta_modelPlanID(ctx context.Context, field graphql.CollectedField, obj *models.NewDiscussionRepliedActivityMeta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_NewDiscussionRepliedActivityMeta_modelPlanID(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ModelPlanID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(uuid.UUID) + fc.Result = res + return ec.marshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_NewDiscussionRepliedActivityMeta_modelPlanID(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "NewDiscussionRepliedActivityMeta", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type UUID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _NewDiscussionRepliedActivityMeta_modelPlan(ctx context.Context, field graphql.CollectedField, obj *models.NewDiscussionRepliedActivityMeta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_NewDiscussionRepliedActivityMeta_modelPlan(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.NewDiscussionRepliedActivityMeta().ModelPlan(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*models.ModelPlan) + fc.Result = res + return ec.marshalNModelPlan2ᚖgithubᚗcomᚋcmsgovᚋmintᚑappᚋpkgᚋmodelsᚐModelPlan(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_NewDiscussionRepliedActivityMeta_modelPlan(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "NewDiscussionRepliedActivityMeta", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_ModelPlan_id(ctx, field) + case "modelName": + return ec.fieldContext_ModelPlan_modelName(ctx, field) + case "abbreviation": + return ec.fieldContext_ModelPlan_abbreviation(ctx, field) + case "archived": + return ec.fieldContext_ModelPlan_archived(ctx, field) + case "createdBy": + return ec.fieldContext_ModelPlan_createdBy(ctx, field) + case "createdByUserAccount": + return ec.fieldContext_ModelPlan_createdByUserAccount(ctx, field) + case "createdDts": + return ec.fieldContext_ModelPlan_createdDts(ctx, field) + case "modifiedBy": + return ec.fieldContext_ModelPlan_modifiedBy(ctx, field) + case "modifiedByUserAccount": + return ec.fieldContext_ModelPlan_modifiedByUserAccount(ctx, field) + case "modifiedDts": + return ec.fieldContext_ModelPlan_modifiedDts(ctx, field) + case "basics": + return ec.fieldContext_ModelPlan_basics(ctx, field) + case "generalCharacteristics": + return ec.fieldContext_ModelPlan_generalCharacteristics(ctx, field) + case "participantsAndProviders": + return ec.fieldContext_ModelPlan_participantsAndProviders(ctx, field) + case "beneficiaries": + return ec.fieldContext_ModelPlan_beneficiaries(ctx, field) + case "opsEvalAndLearning": + return ec.fieldContext_ModelPlan_opsEvalAndLearning(ctx, field) + case "collaborators": + return ec.fieldContext_ModelPlan_collaborators(ctx, field) + case "documents": + return ec.fieldContext_ModelPlan_documents(ctx, field) + case "discussions": + return ec.fieldContext_ModelPlan_discussions(ctx, field) + case "payments": + return ec.fieldContext_ModelPlan_payments(ctx, field) + case "status": + return ec.fieldContext_ModelPlan_status(ctx, field) + case "isFavorite": + return ec.fieldContext_ModelPlan_isFavorite(ctx, field) + case "isCollaborator": + return ec.fieldContext_ModelPlan_isCollaborator(ctx, field) + case "crs": + return ec.fieldContext_ModelPlan_crs(ctx, field) + case "tdls": + return ec.fieldContext_ModelPlan_tdls(ctx, field) + case "prepareForClearance": + return ec.fieldContext_ModelPlan_prepareForClearance(ctx, field) + case "nameHistory": + return ec.fieldContext_ModelPlan_nameHistory(ctx, field) + case "operationalNeeds": + return ec.fieldContext_ModelPlan_operationalNeeds(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type ModelPlan", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _NewDiscussionRepliedActivityMeta_discussionID(ctx context.Context, field graphql.CollectedField, obj *models.NewDiscussionRepliedActivityMeta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_NewDiscussionRepliedActivityMeta_discussionID(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.DiscussionID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(uuid.UUID) + fc.Result = res + return ec.marshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_NewDiscussionRepliedActivityMeta_discussionID(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "NewDiscussionRepliedActivityMeta", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type UUID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _NewDiscussionRepliedActivityMeta_discussion(ctx context.Context, field graphql.CollectedField, obj *models.NewDiscussionRepliedActivityMeta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_NewDiscussionRepliedActivityMeta_discussion(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.NewDiscussionRepliedActivityMeta().Discussion(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*models.PlanDiscussion) + fc.Result = res + return ec.marshalNPlanDiscussion2ᚖgithubᚗcomᚋcmsgovᚋmintᚑappᚋpkgᚋmodelsᚐPlanDiscussion(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_NewDiscussionRepliedActivityMeta_discussion(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "NewDiscussionRepliedActivityMeta", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_PlanDiscussion_id(ctx, field) + case "modelPlanID": + return ec.fieldContext_PlanDiscussion_modelPlanID(ctx, field) + case "content": + return ec.fieldContext_PlanDiscussion_content(ctx, field) + case "userRole": + return ec.fieldContext_PlanDiscussion_userRole(ctx, field) + case "userRoleDescription": + return ec.fieldContext_PlanDiscussion_userRoleDescription(ctx, field) + case "replies": + return ec.fieldContext_PlanDiscussion_replies(ctx, field) + case "isAssessment": + return ec.fieldContext_PlanDiscussion_isAssessment(ctx, field) + case "createdBy": + return ec.fieldContext_PlanDiscussion_createdBy(ctx, field) + case "createdByUserAccount": + return ec.fieldContext_PlanDiscussion_createdByUserAccount(ctx, field) + case "createdDts": + return ec.fieldContext_PlanDiscussion_createdDts(ctx, field) + case "modifiedBy": + return ec.fieldContext_PlanDiscussion_modifiedBy(ctx, field) + case "modifiedByUserAccount": + return ec.fieldContext_PlanDiscussion_modifiedByUserAccount(ctx, field) + case "modifiedDts": + return ec.fieldContext_PlanDiscussion_modifiedDts(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type PlanDiscussion", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _NewDiscussionRepliedActivityMeta_replyID(ctx context.Context, field graphql.CollectedField, obj *models.NewDiscussionRepliedActivityMeta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_NewDiscussionRepliedActivityMeta_replyID(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.ReplyID, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(uuid.UUID) + fc.Result = res + return ec.marshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_NewDiscussionRepliedActivityMeta_replyID(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "NewDiscussionRepliedActivityMeta", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type UUID does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _NewDiscussionRepliedActivityMeta_reply(ctx context.Context, field graphql.CollectedField, obj *models.NewDiscussionRepliedActivityMeta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_NewDiscussionRepliedActivityMeta_reply(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return ec.resolvers.NewDiscussionRepliedActivityMeta().Reply(rctx, obj) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(*models.DiscussionReply) + fc.Result = res + return ec.marshalNDiscussionReply2ᚖgithubᚗcomᚋcmsgovᚋmintᚑappᚋpkgᚋmodelsᚐDiscussionReply(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_NewDiscussionRepliedActivityMeta_reply(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "NewDiscussionRepliedActivityMeta", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_DiscussionReply_id(ctx, field) + case "discussionID": + return ec.fieldContext_DiscussionReply_discussionID(ctx, field) + case "content": + return ec.fieldContext_DiscussionReply_content(ctx, field) + case "userRole": + return ec.fieldContext_DiscussionReply_userRole(ctx, field) + case "userRoleDescription": + return ec.fieldContext_DiscussionReply_userRoleDescription(ctx, field) + case "isAssessment": + return ec.fieldContext_DiscussionReply_isAssessment(ctx, field) + case "createdBy": + return ec.fieldContext_DiscussionReply_createdBy(ctx, field) + case "createdByUserAccount": + return ec.fieldContext_DiscussionReply_createdByUserAccount(ctx, field) + case "createdDts": + return ec.fieldContext_DiscussionReply_createdDts(ctx, field) + case "modifiedBy": + return ec.fieldContext_DiscussionReply_modifiedBy(ctx, field) + case "modifiedByUserAccount": + return ec.fieldContext_DiscussionReply_modifiedByUserAccount(ctx, field) + case "modifiedDts": + return ec.fieldContext_DiscussionReply_modifiedDts(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type DiscussionReply", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _NewDiscussionRepliedActivityMeta_content(ctx context.Context, field graphql.CollectedField, obj *models.NewDiscussionRepliedActivityMeta) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_NewDiscussionRepliedActivityMeta_content(ctx, field) + if err != nil { + return graphql.Null + } + ctx = graphql.WithFieldContext(ctx, fc) + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = graphql.Null + } + }() + resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { + ctx = rctx // use context from middleware stack in children + return obj.Content, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + if !graphql.HasFieldError(ctx, fc) { + ec.Errorf(ctx, "must not be null") + } + return graphql.Null + } + res := resTmp.(string) + fc.Result = res + return ec.marshalNString2string(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_NewDiscussionRepliedActivityMeta_content(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "NewDiscussionRepliedActivityMeta", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type String does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _OperationalNeed_id(ctx context.Context, field graphql.CollectedField, obj *models.OperationalNeed) (ret graphql.Marshaler) { fc, err := ec.fieldContext_OperationalNeed_id(ctx, field) if err != nil { @@ -62278,6 +62878,11 @@ func (ec *executionContext) _ActivityMetaData(ctx context.Context, sel ast.Selec return graphql.Null } return ec._DailyDigestCompleteActivityMeta(ctx, sel, obj) + case *models.NewDiscussionRepliedActivityMeta: + if obj == nil { + return graphql.Null + } + return ec._NewDiscussionRepliedActivityMeta(ctx, sel, obj) default: panic(fmt.Errorf("unexpected type %T", obj)) } @@ -65173,6 +65778,178 @@ func (ec *executionContext) _NDAInfo(ctx context.Context, sel ast.SelectionSet, return out } +var newDiscussionRepliedActivityMetaImplementors = []string{"NewDiscussionRepliedActivityMeta", "ActivityMetaData"} + +func (ec *executionContext) _NewDiscussionRepliedActivityMeta(ctx context.Context, sel ast.SelectionSet, obj *models.NewDiscussionRepliedActivityMeta) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, newDiscussionRepliedActivityMetaImplementors) + + out := graphql.NewFieldSet(fields) + deferred := make(map[string]*graphql.FieldSet) + for i, field := range fields { + switch field.Name { + case "__typename": + out.Values[i] = graphql.MarshalString("NewDiscussionRepliedActivityMeta") + case "version": + out.Values[i] = ec._NewDiscussionRepliedActivityMeta_version(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "type": + out.Values[i] = ec._NewDiscussionRepliedActivityMeta_type(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "modelPlanID": + out.Values[i] = ec._NewDiscussionRepliedActivityMeta_modelPlanID(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "modelPlan": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._NewDiscussionRepliedActivityMeta_modelPlan(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "discussionID": + out.Values[i] = ec._NewDiscussionRepliedActivityMeta_discussionID(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "discussion": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._NewDiscussionRepliedActivityMeta_discussion(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "replyID": + out.Values[i] = ec._NewDiscussionRepliedActivityMeta_replyID(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "reply": + field := field + + innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._NewDiscussionRepliedActivityMeta_reply(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } + + if field.Deferrable != nil { + dfs, ok := deferred[field.Deferrable.Label] + di := 0 + if ok { + dfs.AddField(field) + di = len(dfs.Values) - 1 + } else { + dfs = graphql.NewFieldSet([]graphql.CollectedField{field}) + deferred[field.Deferrable.Label] = dfs + } + dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler { + return innerFunc(ctx, dfs) + }) + + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "content": + out.Values[i] = ec._NewDiscussionRepliedActivityMeta_content(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + default: + panic("unknown field " + strconv.Quote(field.Name)) + } + } + out.Dispatch(ctx) + if out.Invalids > 0 { + return graphql.Null + } + + atomic.AddInt32(&ec.deferred, int32(len(deferred))) + + for label, dfs := range deferred { + ec.processDeferredGroup(graphql.DeferredGroup{ + Label: label, + Path: graphql.GetPath(ctx), + FieldSet: dfs, + Context: ctx, + }) + } + + return out +} + var operationalNeedImplementors = []string{"OperationalNeed"} func (ec *executionContext) _OperationalNeed(ctx context.Context, sel ast.SelectionSet, obj *models.OperationalNeed) graphql.Marshaler { diff --git a/pkg/graph/resolvers/activity.resolvers.go b/pkg/graph/resolvers/activity.resolvers.go index cbb3431a88..e104abfcc1 100644 --- a/pkg/graph/resolvers/activity.resolvers.go +++ b/pkg/graph/resolvers/activity.resolvers.go @@ -24,6 +24,23 @@ func (r *dailyDigestCompleteActivityMetaResolver) AnalyzedAudits(ctx context.Con return loaders.AnalyzedAuditGetByModelPlanIDsAndDate(ctx, obj.ModelPlanIDs, obj.Date) } +// ModelPlan is the resolver for the modelPlan field. +func (r *newDiscussionRepliedActivityMetaResolver) ModelPlan(ctx context.Context, obj *models.NewDiscussionRepliedActivityMeta) (*models.ModelPlan, error) { + return ModelPlanGetByIDLOADER(ctx, obj.ModelPlanID) +} + +// Discussion is the resolver for the discussion field. +func (r *newDiscussionRepliedActivityMetaResolver) Discussion(ctx context.Context, obj *models.NewDiscussionRepliedActivityMeta) (*models.PlanDiscussion, error) { + logger := appcontext.ZLogger(ctx) + return PlanDiscussionGetByID(ctx, r.store, logger, obj.DiscussionID) +} + +// Reply is the resolver for the reply field. +func (r *newDiscussionRepliedActivityMetaResolver) Reply(ctx context.Context, obj *models.NewDiscussionRepliedActivityMeta) (*models.DiscussionReply, error) { + logger := appcontext.ZLogger(ctx) + return DiscussionReplyGetByID(ctx, r.store, logger, obj.ReplyID) +} + // ModelPlan is the resolver for the modelPlan field. func (r *taggedInDiscussionReplyActivityMetaResolver) ModelPlan(ctx context.Context, obj *models.TaggedInDiscussionReplyActivityMeta) (*models.ModelPlan, error) { return ModelPlanGetByIDLOADER(ctx, obj.ModelPlanID) @@ -60,6 +77,11 @@ func (r *Resolver) DailyDigestCompleteActivityMeta() generated.DailyDigestComple return &dailyDigestCompleteActivityMetaResolver{r} } +// NewDiscussionRepliedActivityMeta returns generated.NewDiscussionRepliedActivityMetaResolver implementation. +func (r *Resolver) NewDiscussionRepliedActivityMeta() generated.NewDiscussionRepliedActivityMetaResolver { + return &newDiscussionRepliedActivityMetaResolver{r} +} + // TaggedInDiscussionReplyActivityMeta returns generated.TaggedInDiscussionReplyActivityMetaResolver implementation. func (r *Resolver) TaggedInDiscussionReplyActivityMeta() generated.TaggedInDiscussionReplyActivityMetaResolver { return &taggedInDiscussionReplyActivityMetaResolver{r} @@ -72,5 +94,6 @@ func (r *Resolver) TaggedInPlanDiscussionActivityMeta() generated.TaggedInPlanDi type activityResolver struct{ *Resolver } type dailyDigestCompleteActivityMetaResolver struct{ *Resolver } +type newDiscussionRepliedActivityMetaResolver struct{ *Resolver } type taggedInDiscussionReplyActivityMetaResolver struct{ *Resolver } type taggedInPlanDiscussionActivityMetaResolver struct{ *Resolver } diff --git a/pkg/graph/resolvers/plan_discussion.go b/pkg/graph/resolvers/plan_discussion.go index 6a00a03fa9..f940f73000 100644 --- a/pkg/graph/resolvers/plan_discussion.go +++ b/pkg/graph/resolvers/plan_discussion.go @@ -454,13 +454,24 @@ func CreateDiscussionReply( if err != nil { return reply, err } + // Create Activity and notifications in the DB _, notificationErr := notifications.ActivityTaggedInDiscussionReplyCreate(ctx, tx, principal.Account().ID, discussion.ModelPlanID, discussion.ID, reply.ID, reply.Content, loaders.UserNotificationPreferencesGetByUserID) if notificationErr != nil { return nil, fmt.Errorf("unable to generate notifications, %w", notificationErr) } - go func() { + discussionCreatorPref, err := UserNotificationPreferencesGetByUserID(ctx, discussion.CreatedBy) + if err != nil { + return nil, fmt.Errorf("unable to get user notification preference, Notification not created %w", err) + } + + _, err = notifications.ActivityNewDiscussionRepliedCreate(ctx, tx, reply.CreatedBy, discussion.ModelPlanID, discussion.ID, discussion.CreatedBy, reply.ID, reply.Content, discussionCreatorPref) + if notificationErr != nil { + return nil, fmt.Errorf("unable to generate notifications, %w", notificationErr) + } + + go func() { replyUser := principal.Account() commonName := replyUser.CommonName @@ -470,22 +481,24 @@ func CreateDiscussionReply( zap.Error(err)) } - errReplyEmail := sendDiscussionReplyEmails( - ctx, - store, - logger, - emailService, - emailTemplateService, - addressBook, - discussion, - reply, - modelPlan, - replyUser, - ) - if errReplyEmail != nil { - logger.Error("error sending tagged in plan discussion reply emails to tagged users and teams", - zap.String("discussionID", discussion.ID.String()), - zap.Error(errReplyEmail)) + if discussionCreatorPref.NewDiscussionReply.SendEmail() { + errReplyEmail := sendDiscussionReplyEmails( + ctx, + store, + logger, + emailService, + emailTemplateService, + addressBook, + discussion, + reply, + modelPlan, + replyUser, + ) + if errReplyEmail != nil { + logger.Error("error sending tagged in plan discussion reply emails to tagged users and teams", + zap.String("discussionID", discussion.ID.String()), + zap.Error(errReplyEmail)) + } } err = sendPlanDiscussionTagEmails( @@ -511,6 +524,7 @@ func CreateDiscussionReply( } } }() + return reply, err }) if err != nil { diff --git a/pkg/graph/schema/types/activity.graphql b/pkg/graph/schema/types/activity.graphql index bfe8596fa1..742119e779 100644 --- a/pkg/graph/schema/types/activity.graphql +++ b/pkg/graph/schema/types/activity.graphql @@ -13,7 +13,7 @@ enum ActivityType { """ ActivityMetaData is a type that represents all the data that can be captured in an Activity """ -union ActivityMetaData = TaggedInPlanDiscussionActivityMeta | TaggedInDiscussionReplyActivityMeta | DailyDigestCompleteActivityMeta +union ActivityMetaData = TaggedInPlanDiscussionActivityMeta | TaggedInDiscussionReplyActivityMeta | DailyDigestCompleteActivityMeta | NewDiscussionRepliedActivityMeta type TaggedInPlanDiscussionActivityMeta { version: Int! @@ -37,6 +37,17 @@ type TaggedInDiscussionReplyActivityMeta { content: String! } +type NewDiscussionRepliedActivityMeta { + version: Int! + type: ActivityType! + modelPlanID: UUID! + modelPlan: ModelPlan! + discussionID: UUID! + discussion: PlanDiscussion! + replyID: UUID! + reply: DiscussionReply! + content: String! +} type DailyDigestCompleteActivityMeta { version: Int! type: ActivityType! diff --git a/pkg/models/new_discussion_replied_meta.go b/pkg/models/new_discussion_replied_meta.go new file mode 100644 index 0000000000..69e7d10878 --- /dev/null +++ b/pkg/models/new_discussion_replied_meta.go @@ -0,0 +1,69 @@ +package models + +import ( + "database/sql/driver" + "encoding/json" + "errors" + + "github.com/google/uuid" +) + +// NewDiscussionRepliedActivityMeta represents the notification data that is relevant to a new Discussion Reply +type NewDiscussionRepliedActivityMeta struct { + ActivityMetaBaseStruct + discussionRelation + modelPlanRelation + ReplyID uuid.UUID `json:"replyID"` + Content string `json:"content"` +} + +// newNewDiscussionRepliedActivityMeta creates a New NewDiscussionRepliedActivityMeta +func newNewDiscussionRepliedActivityMeta(modelPlanID uuid.UUID, discussionID uuid.UUID, replyID uuid.UUID, content string) *NewDiscussionRepliedActivityMeta { + version := 0 //iterate this if this type ever updates + return &NewDiscussionRepliedActivityMeta{ + ActivityMetaBaseStruct: NewActivityMetaBaseStruct(ActivityNewDiscussionReply, version), + discussionRelation: NewDiscussionRelation(discussionID), + modelPlanRelation: NewModelPlanRelation(modelPlanID), + ReplyID: replyID, + Content: content, + } + +} + +// NewNewDiscussionRepliedActivity creates a New New Discussion Replied type of Activity +func NewNewDiscussionRepliedActivity(actorID uuid.UUID, modelPlanID uuid.UUID, discussionID uuid.UUID, replyID uuid.UUID, content string) *Activity { + return &Activity{ + baseStruct: NewBaseStruct(actorID), + ActorID: actorID, + EntityID: discussionID, + ActivityType: ActivityNewDiscussionReply, + MetaData: newNewDiscussionRepliedActivityMeta(modelPlanID, discussionID, replyID, content), + } +} + +// Future Enhancement: --> Refactor these all to have a generic scan / value + +// Value allows us to satisfy the valuer interface so we can write to the database +// We need to do a specific implementation instead of relying on the implementation of the embedded struct, as that will only serialize the common data +func (d NewDiscussionRepliedActivityMeta) Value() (driver.Value, error) { + + j, err := json.Marshal(d) + return j, err +} + +// Scan implements the scanner interface so we can translate the JSONb from the db to an object in GO +func (d *NewDiscussionRepliedActivityMeta) Scan(src interface{}) error { + if src == nil { + return nil + } + source, ok := src.([]byte) + if !ok { + return errors.New("type assertion .([]byte) failed") + } + err := json.Unmarshal(source, d) + if err != nil { + return err + } + + return nil +} diff --git a/pkg/notifications/activity.go b/pkg/notifications/activity.go index e697e3ba0e..f987cb4c9c 100644 --- a/pkg/notifications/activity.go +++ b/pkg/notifications/activity.go @@ -102,6 +102,14 @@ func parseRawActivityMetaData(activityType models.ActivityType, rawMetaDataJSON } return &meta, nil + case models.ActivityNewDiscussionReply: + // Deserialize the raw JSON into NewDiscussionReplyActivityMeta + meta := models.NewDiscussionRepliedActivityMeta{} + if err := json.Unmarshal(rawData, &meta); err != nil { + return nil, err + } + return &meta, nil + // Add cases for other ActivityTypes as needed default: diff --git a/pkg/notifications/new_discussion_replied_meta.go b/pkg/notifications/new_discussion_replied_meta.go new file mode 100644 index 0000000000..5d7cc6917c --- /dev/null +++ b/pkg/notifications/new_discussion_replied_meta.go @@ -0,0 +1,28 @@ +package notifications + +import ( + "context" + + "github.com/google/uuid" + + "github.com/cmsgov/mint-app/pkg/models" + "github.com/cmsgov/mint-app/pkg/sqlutils" +) + +// ActivityNewDiscussionRepliedCreate creates an activity for when a Discussion is replied to. +func ActivityNewDiscussionRepliedCreate(ctx context.Context, np sqlutils.NamedPreparer, actorID uuid.UUID, modelPlanID uuid.UUID, discussionID uuid.UUID, discussionCreatorID uuid.UUID, replyID uuid.UUID, discussionReplyContent models.TaggedHTML, userPreferences *models.UserNotificationPreferences) (*models.Activity, error) { + + activity := models.NewNewDiscussionRepliedActivity(actorID, modelPlanID, discussionID, replyID, discussionReplyContent.RawContent.String()) + + retActivity, actErr := activityCreate(ctx, np, activity) + if actErr != nil { + return nil, actErr + } + + _, err := userNotificationCreate(ctx, np, retActivity, discussionCreatorID, userPreferences.NewDiscussionReply) + if err != nil { + return nil, err + } + + return retActivity, nil +} diff --git a/pkg/notifications/new_discussion_replied_meta_test.go b/pkg/notifications/new_discussion_replied_meta_test.go new file mode 100644 index 0000000000..80234515cf --- /dev/null +++ b/pkg/notifications/new_discussion_replied_meta_test.go @@ -0,0 +1,47 @@ +package notifications + +import ( + "github.com/google/uuid" + + "github.com/cmsgov/mint-app/pkg/models" +) + +func (suite *NotificationsSuite) TestActivityNewDiscussionRepliedCreate() { + + html := `

Hey there! Are you available for a quick sync on this issue? Thanks!

` + taggedContent, err := models.NewTaggedContentFromString(html) + suite.NoError(err) + input := models.TaggedHTML(taggedContent) + + modelPlanID := uuid.New() // We are just choosing a valid UUID to set for the entityID + discussionID := uuid.New() + replyID := uuid.New() + actorID := suite.testConfigs.Principal.Account().ID + userPrefs := models.NewUserNotificationPreferences(actorID) + + discussionCreator, err := suite.testConfigs.GetTestPrincipal(suite.testConfigs.Store, "FRED") + suite.NoError(err) + + testActivity, err := ActivityNewDiscussionRepliedCreate(suite.testConfigs.Context, suite.testConfigs.Store, actorID, modelPlanID, discussionID, discussionCreator.Account().ID, replyID, input, userPrefs) + suite.NoError(err) + + // Assert about notification object creation + suite.NotNil(testActivity) + suite.EqualValues(models.ActivityNewDiscussionReply, testActivity.ActivityType) + + // Assert about notification delivery + actorNots, err := UserNotificationCollectionGetByUser(suite.testConfigs.Context, suite.testConfigs.Store, suite.testConfigs.Principal) + suite.NoError(err) + suite.EqualValues(0, actorNots.NumUnreadNotifications()) + + recipientNots, err := UserNotificationCollectionGetByUser(suite.testConfigs.Context, suite.testConfigs.Store, discussionCreator) + suite.NoError(err) + suite.EqualValues(1, recipientNots.NumUnreadNotifications()) + + // Assert about meta data values + meta := suite.deserializeActivityMetadata(testActivity) + suite.EqualValues(input.RawContent.String(), meta.(*models.NewDiscussionRepliedActivityMeta).Content) + suite.EqualValues(discussionID, meta.(*models.NewDiscussionRepliedActivityMeta).DiscussionID) + suite.EqualValues(modelPlanID, meta.(*models.NewDiscussionRepliedActivityMeta).ModelPlanID) + suite.EqualValues(replyID, meta.(*models.NewDiscussionRepliedActivityMeta).ReplyID) +} diff --git a/pkg/notifications/notifications_test.go b/pkg/notifications/notifications_test.go index fafc0e77c5..c03687dbcf 100644 --- a/pkg/notifications/notifications_test.go +++ b/pkg/notifications/notifications_test.go @@ -3,6 +3,8 @@ package notifications import ( "testing" + "github.com/cmsgov/mint-app/pkg/models" + "github.com/stretchr/testify/suite" "github.com/cmsgov/mint-app/pkg/testconfig" @@ -27,3 +29,17 @@ func TestNotificationsSuite(t *testing.T) { suite.Run(t, rs) } + +// deserializeActivityMetadata is a helper function to deserialize the metadata of an activity +// It asserts that the metadata is not deserialized, and that the raw metadata is deserialized +// This method is used to test the deserialization of the metadata of an activity in notification unit tests +func (suite *NotificationsSuite) deserializeActivityMetadata(testActivity *models.Activity) models.ActivityMetaData { + suite.Nil(testActivity.MetaData) // Assert meta data is not deserialized here + suite.NotNil(testActivity.MetaDataRaw) // Assert meta data can be deserialized + + meta, err := parseRawActivityMetaData(testActivity.ActivityType, testActivity.MetaDataRaw) + suite.NoError(err) + suite.NotNil(meta) + + return meta +} diff --git a/src/gql/apolloGQL/Notifications/GetNotifications.ts b/src/gql/apolloGQL/Notifications/GetNotifications.ts index f0900e5684..ef7f68c866 100644 --- a/src/gql/apolloGQL/Notifications/GetNotifications.ts +++ b/src/gql/apolloGQL/Notifications/GetNotifications.ts @@ -21,6 +21,17 @@ export default gql(/* GraphQL */ ` } metaData { __typename + ... on NewDiscussionRepliedActivityMeta { + version + type + discussionID + replyID + modelPlanID + modelPlan { + modelName + } + content + } ... on TaggedInPlanDiscussionActivityMeta { version type diff --git a/src/gql/gen/graphql.ts b/src/gql/gen/graphql.ts index b466779dac..9b25202927 100644 --- a/src/gql/gen/graphql.ts +++ b/src/gql/gen/graphql.ts @@ -55,7 +55,7 @@ export type Activity = { }; /** ActivityMetaData is a type that represents all the data that can be captured in an Activity */ -export type ActivityMetaData = DailyDigestCompleteActivityMeta | TaggedInDiscussionReplyActivityMeta | TaggedInPlanDiscussionActivityMeta; +export type ActivityMetaData = DailyDigestCompleteActivityMeta | NewDiscussionRepliedActivityMeta | TaggedInDiscussionReplyActivityMeta | TaggedInPlanDiscussionActivityMeta; /** ActivityType represents the possible activities that happen in application that might result in a notification */ export enum ActivityType { @@ -986,6 +986,19 @@ export type NdaInfo = { agreedDts?: Maybe; }; +export type NewDiscussionRepliedActivityMeta = { + __typename: 'NewDiscussionRepliedActivityMeta'; + content: Scalars['String']['output']; + discussion: PlanDiscussion; + discussionID: Scalars['UUID']['output']; + modelPlan: ModelPlan; + modelPlanID: Scalars['UUID']['output']; + reply: DiscussionReply; + replyID: Scalars['UUID']['output']; + type: ActivityType; + version: Scalars['Int']['output']; +}; + export enum NonClaimsBasedPayType { ADVANCED_PAYMENT = 'ADVANCED_PAYMENT', BUNDLED_EPISODE_OF_CARE = 'BUNDLED_EPISODE_OF_CARE', @@ -3362,7 +3375,7 @@ export type GetNotificationSettingsQuery = { __typename: 'Query', currentUser: { export type GetNotificationsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetNotificationsQuery = { __typename: 'Query', currentUser: { __typename: 'CurrentUser', notifications: { __typename: 'UserNotifications', numUnreadNotifications: number, notifications: Array<{ __typename: 'UserNotification', id: UUID, isRead: boolean, inAppSent: boolean, emailSent: boolean, createdDts: Time, activity: { __typename: 'Activity', activityType: ActivityType, entityID: UUID, actorID: UUID, actorUserAccount: { __typename: 'UserAccount', commonName: string }, metaData: { __typename: 'DailyDigestCompleteActivityMeta', version: number, type: ActivityType, modelPlanIDs: Array, date: Time, analyzedAudits: Array<{ __typename: 'AnalyzedAudit', id: UUID, modelPlanID: UUID, modelName: string, date: Time, changes: { __typename: 'AnalyzedAuditChange', modelPlan?: { __typename: 'AnalyzedModelPlan', oldName?: string | null, statusChanges?: Array | null } | null, documents?: { __typename: 'AnalyzedDocuments', count?: number | null } | null, crTdls?: { __typename: 'AnalyzedCrTdls', activity?: boolean | null } | null, planSections?: { __typename: 'AnalyzedPlanSections', updated: Array, readyForReview: Array, readyForClearance: Array } | null, modelLeads?: { __typename: 'AnalyzedModelLeads', added: Array<{ __typename: 'AnalyzedModelLeadInfo', id: UUID, commonName: string }> } | null, planDiscussions?: { __typename: 'AnalyzedPlanDiscussions', activity?: boolean | null } | null } }> } | { __typename: 'TaggedInDiscussionReplyActivityMeta', version: number, type: ActivityType, modelPlanID: UUID, discussionID: UUID, replyID: UUID, content: string, modelPlan: { __typename: 'ModelPlan', modelName: string } } | { __typename: 'TaggedInPlanDiscussionActivityMeta', version: number, type: ActivityType, modelPlanID: UUID, discussionID: UUID, content: string, modelPlan: { __typename: 'ModelPlan', modelName: string } } } }> } } }; +export type GetNotificationsQuery = { __typename: 'Query', currentUser: { __typename: 'CurrentUser', notifications: { __typename: 'UserNotifications', numUnreadNotifications: number, notifications: Array<{ __typename: 'UserNotification', id: UUID, isRead: boolean, inAppSent: boolean, emailSent: boolean, createdDts: Time, activity: { __typename: 'Activity', activityType: ActivityType, entityID: UUID, actorID: UUID, actorUserAccount: { __typename: 'UserAccount', commonName: string }, metaData: { __typename: 'DailyDigestCompleteActivityMeta', version: number, type: ActivityType, modelPlanIDs: Array, date: Time, analyzedAudits: Array<{ __typename: 'AnalyzedAudit', id: UUID, modelPlanID: UUID, modelName: string, date: Time, changes: { __typename: 'AnalyzedAuditChange', modelPlan?: { __typename: 'AnalyzedModelPlan', oldName?: string | null, statusChanges?: Array | null } | null, documents?: { __typename: 'AnalyzedDocuments', count?: number | null } | null, crTdls?: { __typename: 'AnalyzedCrTdls', activity?: boolean | null } | null, planSections?: { __typename: 'AnalyzedPlanSections', updated: Array, readyForReview: Array, readyForClearance: Array } | null, modelLeads?: { __typename: 'AnalyzedModelLeads', added: Array<{ __typename: 'AnalyzedModelLeadInfo', id: UUID, commonName: string }> } | null, planDiscussions?: { __typename: 'AnalyzedPlanDiscussions', activity?: boolean | null } | null } }> } | { __typename: 'NewDiscussionRepliedActivityMeta', version: number, type: ActivityType, discussionID: UUID, replyID: UUID, modelPlanID: UUID, content: string, modelPlan: { __typename: 'ModelPlan', modelName: string } } | { __typename: 'TaggedInDiscussionReplyActivityMeta', version: number, type: ActivityType, modelPlanID: UUID, discussionID: UUID, replyID: UUID, content: string, modelPlan: { __typename: 'ModelPlan', modelName: string } } | { __typename: 'TaggedInPlanDiscussionActivityMeta', version: number, type: ActivityType, modelPlanID: UUID, discussionID: UUID, content: string, modelPlan: { __typename: 'ModelPlan', modelName: string } } } }> } } }; export type GetPollNotificationsQueryVariables = Exact<{ [key: string]: never; }>; @@ -7624,6 +7637,17 @@ export const GetNotificationsDocument = gql` } metaData { __typename + ... on NewDiscussionRepliedActivityMeta { + version + type + discussionID + replyID + modelPlanID + modelPlan { + modelName + } + content + } ... on TaggedInPlanDiscussionActivityMeta { version type diff --git a/src/gql/gen/types/GetNotifications.ts b/src/gql/gen/types/GetNotifications.ts index ba62daeb67..3038a96357 100644 --- a/src/gql/gen/types/GetNotifications.ts +++ b/src/gql/gen/types/GetNotifications.ts @@ -14,6 +14,22 @@ export interface GetNotifications_currentUser_notifications_notifications_activi commonName: string; } +export interface GetNotifications_currentUser_notifications_notifications_activity_metaData_NewDiscussionRepliedActivityMeta_modelPlan { + __typename: "ModelPlan"; + modelName: string; +} + +export interface GetNotifications_currentUser_notifications_notifications_activity_metaData_NewDiscussionRepliedActivityMeta { + __typename: "NewDiscussionRepliedActivityMeta"; + version: number; + type: ActivityType; + discussionID: UUID; + replyID: UUID; + modelPlanID: UUID; + modelPlan: GetNotifications_currentUser_notifications_notifications_activity_metaData_NewDiscussionRepliedActivityMeta_modelPlan; + content: string; +} + export interface GetNotifications_currentUser_notifications_notifications_activity_metaData_TaggedInPlanDiscussionActivityMeta_modelPlan { __typename: "ModelPlan"; modelName: string; @@ -115,7 +131,7 @@ export interface GetNotifications_currentUser_notifications_notifications_activi analyzedAudits: GetNotifications_currentUser_notifications_notifications_activity_metaData_DailyDigestCompleteActivityMeta_analyzedAudits[]; } -export type GetNotifications_currentUser_notifications_notifications_activity_metaData = GetNotifications_currentUser_notifications_notifications_activity_metaData_TaggedInPlanDiscussionActivityMeta | GetNotifications_currentUser_notifications_notifications_activity_metaData_TaggedInDiscussionReplyActivityMeta | GetNotifications_currentUser_notifications_notifications_activity_metaData_DailyDigestCompleteActivityMeta; +export type GetNotifications_currentUser_notifications_notifications_activity_metaData = GetNotifications_currentUser_notifications_notifications_activity_metaData_NewDiscussionRepliedActivityMeta | GetNotifications_currentUser_notifications_notifications_activity_metaData_TaggedInPlanDiscussionActivityMeta | GetNotifications_currentUser_notifications_notifications_activity_metaData_TaggedInDiscussionReplyActivityMeta | GetNotifications_currentUser_notifications_notifications_activity_metaData_DailyDigestCompleteActivityMeta; export interface GetNotifications_currentUser_notifications_notifications_activity { __typename: "Activity"; diff --git a/src/views/Notifications/Home/_components/IndividualNotification.tsx b/src/views/Notifications/Home/_components/IndividualNotification.tsx index a881b43969..21e1d343bc 100644 --- a/src/views/Notifications/Home/_components/IndividualNotification.tsx +++ b/src/views/Notifications/Home/_components/IndividualNotification.tsx @@ -7,7 +7,6 @@ import { GetNotifications_currentUser_notifications_notifications_activity as No import { arrayOfColors } from 'components/shared/IconInitial'; import MentionTextArea from 'components/shared/MentionTextArea'; -import useCheckResponsiveScreen from 'hooks/useCheckMobile'; import { getTimeElapsed } from 'utils/date'; import { getUserInitials } from 'utils/modelPlan'; @@ -15,6 +14,7 @@ import { ActivityCTA, activityText, isDailyDigest, + isNewDiscussionReply, isTaggedInDiscussion, isTaggedInDiscussionReply } from './_utils'; @@ -43,7 +43,6 @@ const IndividualNotification = ({ const [isExpanded, setIsExpanded] = useState(false); const history = useHistory(); - const isMobile = useCheckResponsiveScreen('mobile'); const [markAsRead] = useMarkNotificationAsReadMutation(); @@ -123,16 +122,14 @@ const IndividualNotification = ({ {name} {activityText(metaData)}

- {!isMobile && - (isTaggedInDiscussion(metaData) || - isTaggedInDiscussionReply(metaData)) && ( - - )} + {!isDailyDigest(metaData) && ( + + )}