diff --git a/EASI.postman_collection.json b/EASI.postman_collection.json index 4743cc5f63..1a753099bd 100644 --- a/EASI.postman_collection.json +++ b/EASI.postman_collection.json @@ -1000,7 +1000,7 @@ "listen": "test", "script": { "exec": [ - "pm.collectionVariables.set(\"SystemIntakeGRBReviewerID\", pm.response.json().data.createSystemIntakeGRBReviewer.id)" + "pm.collectionVariables.set(\"SystemIntakeGRBReviewerID\", pm.response.json().data.createSystemIntakeGRBReviewers.reviewers[0].id)" ], "type": "text/javascript", "packages": {} @@ -1013,8 +1013,8 @@ "body": { "mode": "graphql", "graphql": { - "query": "mutation createS($input: CreateSystemIntakeGRBReviewerInput!) {\n createSystemIntakeGRBReviewer(input: $input) {\n id\n userAccount {\n id\n }\n grbRole\n votingRole\n systemIntakeID\n createdBy\n createdAt\n modifiedBy\n modifiedAt\n }\n}", - "variables": "{\r\n \"input\": {\r\n \"euaUserId\": \"ABCD\",\r\n \"systemIntakeID\": \"8edb237e-ad48-49b2-91cf-8534362bc6cf\",\r\n \"votingRole\": \"NON_VOTING\",\r\n \"grbRole\": \"CMCS_REP\"\r\n }\r\n}" + "query": "mutation createSystemIntakeGRBReviewer($input: CreateSystemIntakeGRBReviewersInput!) {\n createSystemIntakeGRBReviewers(input: $input) {\n reviewers {\n id\n userAccount {\n id\n }\n grbRole\n votingRole\n systemIntakeID\n createdBy\n createdAt\n modifiedBy\n modifiedAt\n }\n \n }\n}", + "variables": "{\r\n \"input\": {\r\n \"systemIntakeID\": \"8edb237e-ad48-49b2-91cf-8534362bc6cf\",\r\n \"reviewers\": {\r\n \"euaUserId\": \"ABCD\",\r\n \"votingRole\": \"NON_VOTING\",\r\n \"grbRole\": \"CMCS_REP\"\r\n }\r\n }\r\n}" } }, "url": { @@ -1124,6 +1124,171 @@ } }, "response": [] + }, + { + "name": "Get GRB Discussions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "query getSystemIntakeGRBReviewers($systemIntakeID: UUID!) {\n systemIntake(id: $systemIntakeID) {\n id\n grbDiscussions {\n initialPost {\n id\n content\n votingRole\n grbRole\n systemIntakeID\n createdAt\n createdByUserAccount {\n givenName\n familyName\n }\n modifiedAt\n modifiedByUserAccount {\n givenName\n familyName\n }\n }\n replies {\n id\n content\n votingRole\n grbRole\n systemIntakeID\n createdAt\n createdByUserAccount {\n givenName\n familyName\n }\n modifiedAt\n modifiedByUserAccount {\n givenName\n familyName\n }\n }\n }\n }\n}", + "variables": "{\r\n \"systemIntakeID\": \"{{systemIntakeID}}\"\r\n}" + } + }, + "url": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] + }, + { + "name": "Create GRB Discussion", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.collectionVariables.set(\"SystemIntakeGRBDiscussionID\", pm.response.json().data.createSystemIntakeGRBDiscussionPost.id)" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "mutation createSystemIntakeGRBDiscussion(\n $input: createSystemIntakeGRBDiscussionPostInput!\n) {\n createSystemIntakeGRBDiscussionPost(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}\n", + "variables": "{\r\n \"input\": {\r\n \"systemIntakeID\": \"{{systemIntakeID}}\",\r\n \"content\": \"
banana apple carburetor Let me look into it, ok? @Mckayla Fritsch!
\"\r\n }\r\n}" + } + }, + "url": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] + }, + { + "name": "Create GRB Discussion Group Tags", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.collectionVariables.set(\"SystemIntakeGRBDiscussionID\", pm.response.json().data.createSystemIntakeGRBDiscussionPost.id)" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "mutation createSystemIntakeGRBDiscussion(\n $input: createSystemIntakeGRBDiscussionPostInput!\n) {\n createSystemIntakeGRBDiscussionPost(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}\n", + "variables": "{\r\n \"input\": {\r\n \"systemIntakeID\": \"{{systemIntakeID}}\",\r\n \"content\": \"banana apple carburetor Let me look into it, ok? @Group middle @Group2!\\\"
\"\r\n }\r\n}" + } + }, + "url": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] + }, + { + "name": "Add GRB Discussion Reply", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "mutation createSystemIntakeGRBDiscussionReply($input: createSystemIntakeGRBDiscussionReplyInput!) {\n createSystemIntakeGRBDiscussionReply(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}", + "variables": "{\r\n \"input\": {\r\n \"initialPostID\": \"{{SystemIntakeGRBDiscussionID}}\",\r\n \"content\": \"monkey kiwi maduros senor @Mckayla Fritsch!
\"\r\n }\r\n}" + } + }, + "url": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] + }, + { + "name": "Add GRB Discussion Reply Group Tags", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "mutation createSystemIntakeGRBDiscussionReply($input: createSystemIntakeGRBDiscussionReplyInput!) {\n createSystemIntakeGRBDiscussionReply(input: $input) {\n id\n content\n grbRole\n votingRole\n systemIntakeID\n createdByUserAccount {\n id\n username\n }\n }\n}", + "variables": "{\r\n \"input\": {\r\n \"initialPostID\": \"{{SystemIntakeGRBDiscussionID}}\",\r\n \"content\": \"Let me look into it, ok? @Group@Group2!
\"\r\n }\r\n}" + } + }, + "url": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] } ] }, @@ -1411,6 +1576,39 @@ }, "response": [] }, + { + "name": "SystemIntake Get All w/ Discussions", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "graphql", + "graphql": { + "query": "query systemIntake {\n systemIntakes(openRequests: true) {\n id\n requestName\n grbDiscussions {\n initialPost {\n id\n content\n }\n replies {\n id\n content\n }\n }\n }\n}", + "variables": "" + } + }, + "url": { + "raw": "{{url}}", + "host": [ + "{{url}}" + ] + } + }, + "response": [] + }, { "name": "SystemIntake Get (Workflow state/statuses)", "event": [ @@ -3824,9 +4022,10 @@ "listen": "test", "script": { "exec": [ - "" + "pm.collectionVariables.set(\"UserAccountID\", pm.response.json().data.userAccount.id)" ], - "type": "text/javascript" + "type": "text/javascript", + "packages": {} } } ], @@ -3836,8 +4035,8 @@ "body": { "mode": "graphql", "graphql": { - "query": "query {\n userAccount(username: \"EASI_SYSTEM\") {\n id\n username\n commonName\n familyName\n givenName\n email\n locale\n }\n}", - "variables": "" + "query": "query($username: String!) {\n userAccount(username: $username) {\n id\n username\n commonName\n familyName\n givenName\n email\n locale\n }\n}", + "variables": "{\r\n \"username\": \"USR1\"\r\n}" } }, "url": { @@ -4073,6 +4272,19 @@ { "key": "CedarAtoIds", "value": "" + }, + { + "key": "SystemIntakeGRBReviewerID", + "value": "" + }, + { + "key": "SystemIntakeGRBDiscussionID", + "value": "", + "type": "string" + }, + { + "key": "UserAccountID", + "value": "" } ] } diff --git a/cmd/devdata/main.go b/cmd/devdata/main.go index cf8c8ba164..23d68814c4 100644 --- a/cmd/devdata/main.go +++ b/cmd/devdata/main.go @@ -15,6 +15,8 @@ import ( "github.com/cms-enterprise/easi-app/cmd/devdata/mock" "github.com/cms-enterprise/easi-app/pkg/appconfig" + "github.com/cms-enterprise/easi-app/pkg/appcontext" + "github.com/cms-enterprise/easi-app/pkg/dataloaders" "github.com/cms-enterprise/easi-app/pkg/local" "github.com/cms-enterprise/easi-app/pkg/models" "github.com/cms-enterprise/easi-app/pkg/storage" @@ -80,10 +82,30 @@ func main() { s3Client := upload.NewS3Client(s3Cfg) - ctx := context.Background() + // nonUserCtx represents a context that has dataloaders, a logger, and other dependencies EXCEPT a principal + // This allows us to use this context as a base to create other contexts with different users without having to re-create all those dependencies each time. + nonUserCtx := context.Background() + nonUserCtx = mock.CtxWithNewDataloaders(nonUserCtx, store) + nonUserCtx = appcontext.WithLogger(nonUserCtx, logger) + nonUserCtx = appcontext.WithUserAccountService(nonUserCtx, dataloaders.GetUserAccountByID) + + // userCtx is a local helper function (so we can not have to pass local variables all the time) that adds a principal + // to a context object and returns it. + // Useful for making calls as different types of users within the dev data + userCtx := func(username string, itGovAdmin bool, trbAdmin bool) context.Context { + return mock.CtxWithPrincipal(nonUserCtx, store, username, itGovAdmin, trbAdmin) + } + // userCtxNonAdmin wraps userCtx to allow creating a regular user context (no admin permissions) + userCtxNonAdmin := func(username string) context.Context { + return userCtx(username, false, false) + } + // userCtxITGovAdmin wraps userCtx to allow creating a context with IT Gov Admin permissions + userCtxITGovAdmin := func(username string) context.Context { + return userCtx(username, true, false) + } - ctx = mock.CtxWithNewDataloaders(ctx, store) - ctx = mock.CtxWithLoggerAndPrincipal(ctx, logger, store, mock.PrincipalUser) + // Create a context that most of the dev data can use + ctx := userCtx(mock.PrincipalUser, true, false) localOktaClient := local.NewOktaAPIClient() @@ -284,7 +306,7 @@ func main() { intakeID = uuid.MustParse("61efa6eb-1976-4431-a158-d89cc00ce31d") intake = makeSystemIntakeAndProgressToStep( ctx, - "System Intake with some different GRB Reviewers", + "System Intake with some different GRB Reviewers (and discussions)", &intakeID, mock.PrincipalUser, store, @@ -328,6 +350,59 @@ func main() { }, ) + // Forgive my incredibly uncreative seed data... + // TODO, remove when they're not supported when we add tagging. Just using it now to show this is HTML + + // Initial Post + postA := createSystemIntakeGRBDiscussionPost(ctx, store, intake, models.TaggedHTML{ + RawContent: "Post A (Replies)", + Tags: nil, + }) + + // First reply is from an Admin -- default context user USR1 is an Admin + createSystemIntakeGRBDiscussionReply(userCtxITGovAdmin("ADMN"), store, postA.ID, models.TaggedHTML{ + RawContent: "Reply A1", + Tags: nil, + }) + + // Then, create a reply from most of the GRB reviewers so we get replies from different non-admin GRB & Voting roles + createSystemIntakeGRBDiscussionReply(ctx, store, postA.ID, models.TaggedHTML{ + RawContent: "Reply A2", + Tags: nil, + }) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR2"), store, postA.ID, models.TaggedHTML{ + RawContent: "Reply A2", + Tags: nil, + }) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR3"), store, postA.ID, models.TaggedHTML{ + RawContent: "Reply A3", + Tags: nil, + }) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR4"), store, postA.ID, models.TaggedHTML{ + RawContent: "Reply A4", + Tags: nil, + }) + + // Make one more thread with some replies + postB := createSystemIntakeGRBDiscussionPost(ctx, store, intake, models.TaggedHTML{ + RawContent: "Post B", + Tags: nil, + }) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR3"), store, postB.ID, models.TaggedHTML{ + RawContent: "Reply B1", + Tags: nil, + }) + createSystemIntakeGRBDiscussionReply(userCtxNonAdmin("USR4"), store, postB.ID, models.TaggedHTML{ + RawContent: "Reply B2", + Tags: nil, + }) + + // Lastly, create a new initial post with no replies + createSystemIntakeGRBDiscussionPost(ctx, store, intake, models.TaggedHTML{ + RawContent: "Post C (No replies)", + Tags: nil, + }) + intakeID = uuid.MustParse("d80cf287-35cb-4e76-b8b3-0467eabd75b8") makeSystemIntakeAndProgressToStep( ctx, diff --git a/cmd/devdata/mock/mock.go b/cmd/devdata/mock/mock.go index 9adc2f0273..d4cf329373 100644 --- a/cmd/devdata/mock/mock.go +++ b/cmd/devdata/mock/mock.go @@ -4,8 +4,6 @@ import ( "context" "fmt" - "go.uber.org/zap" - "github.com/cms-enterprise/easi-app/pkg/appcontext" "github.com/cms-enterprise/easi-app/pkg/authentication" "github.com/cms-enterprise/easi-app/pkg/dataloaders" @@ -37,8 +35,8 @@ func FetchUserInfosMock(ctx context.Context, usernames []string) ([]*models.User return localOktaClient.FetchUserInfos(ctx, usernames) } -// CtxWithLoggerAndPrincipal makes a context with a mocked logger and principal -func CtxWithLoggerAndPrincipal(ctx context.Context, logger *zap.Logger, store *storage.Store, username string) context.Context { +// CtxWithPrincipal makes a context with a principal and automatically calls GetOrCreateUserAccount to populate the user account +func CtxWithPrincipal(ctx context.Context, store *storage.Store, username string, isGRTAdmin bool, isTRBAdmin bool) context.Context { //Future Enhancement: Consider adding this to the seederConfig, and also emb if len(username) < 1 { username = PrincipalUser @@ -51,14 +49,15 @@ func CtxWithLoggerAndPrincipal(ctx context.Context, logger *zap.Logger, store *s } princ := &authentication.EUAPrincipal{ - EUAID: username, - JobCodeEASi: true, - JobCodeGRT: true, - UserAccount: userAccount, + EUAID: username, + JobCodeEASi: true, + JobCodeGRT: isGRTAdmin, + JobCodeTRBAdmin: isTRBAdmin, + UserAccount: userAccount, } - ctx = appcontext.WithLogger(ctx, logger) - ctx = appcontext.WithPrincipal(ctx, princ) - return ctx + + newCtx := appcontext.WithPrincipal(ctx, princ) + return newCtx } func CtxWithNewDataloaders(ctx context.Context, store *storage.Store) context.Context { diff --git a/cmd/devdata/system_intake_grb_review_discussions.go b/cmd/devdata/system_intake_grb_review_discussions.go new file mode 100644 index 0000000000..c1e3871e43 --- /dev/null +++ b/cmd/devdata/system_intake_grb_review_discussions.go @@ -0,0 +1,53 @@ +package main + +import ( + "context" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/graph/resolvers" + "github.com/cms-enterprise/easi-app/pkg/models" + "github.com/cms-enterprise/easi-app/pkg/storage" +) + +func createSystemIntakeGRBDiscussionPost( + ctx context.Context, + store *storage.Store, + intake *models.SystemIntake, + content models.TaggedHTML, +) *models.SystemIntakeGRBReviewDiscussionPost { + post, err := resolvers.CreateSystemIntakeGRBDiscussionPost( + ctx, + store, + nil, // email client + models.CreateSystemIntakeGRBDiscussionPostInput{ + SystemIntakeID: intake.ID, + Content: content, + }, + ) + if err != nil { + panic(err) + } + return post +} + +func createSystemIntakeGRBDiscussionReply( + ctx context.Context, + store *storage.Store, + initialPostID uuid.UUID, + content models.TaggedHTML, +) *models.SystemIntakeGRBReviewDiscussionPost { + reply, err := resolvers.CreateSystemIntakeGRBDiscussionReply( + ctx, + store, + nil, // email client + models.CreateSystemIntakeGRBDiscussionReplyInput{ + InitialPostID: initialPostID, + Content: content, + }, + ) + if err != nil { + panic(err) + } + return reply +} diff --git a/cmd/test_email_templates/main.go b/cmd/test_email_templates/main.go index a4ef0fd1ba..e8ba081bfd 100644 --- a/cmd/test_email_templates/main.go +++ b/cmd/test_email_templates/main.go @@ -629,6 +629,89 @@ func sendITGovEmails(ctx context.Context, client *email.Client) { ) noErr(err) + err = client.SystemIntake.SendGRBReviewDiscussionReplyEmail( + ctx, + email.SendGRBReviewDiscussionReplyEmailInput{ + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + Role: "Voting Member", + DiscussionContent: `banana apple carburetor Let me look into it, ok? @Audrey Abrams!"
`, + Recipient: requesterEmail, + }, + ) + noErr(err) + + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail( + ctx, + email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + Role: "Voting Member", + DiscussionContent: `banana apple carburetor Let me look into it, ok? @Audrey Abrams!"
`, + Recipients: []models.EmailAddress{requesterEmail}, + }, + ) + noErr(err) + + // admin version + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail( + ctx, + email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + Role: "Governance Admin Team", + DiscussionContent: `banana apple carburetor Let me look into it, ok? @Audrey Abrams!"
`, + Recipients: []models.EmailAddress{requesterEmail}, + }, + ) + noErr(err) + + // admin version + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail( + ctx, + email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + Role: "Governance Admin Team", + DiscussionContent: `banana apple carburetor Let me look into it, ok? @Audrey Abrams!"
`, + Recipients: []models.EmailAddress{requesterEmail}, + }, + ) + noErr(err) + + err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail( + ctx, + email.SendGRBReviewDiscussionGroupTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + Role: "Voting Member, CIO", + GroupName: "Governance Admin Team", + DiscussionContent: `banana apple carburetor Let me look into it, ok? @Audrey Abrams!"
`, + Recipients: emailNotificationRecipients, + }, + ) + noErr(err) + + // admin version + err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail( + ctx, + email.SendGRBReviewDiscussionGroupTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: "Discussion Tester #1", + RequestName: "GRB Review Discussion Test", + Role: "Governance Admin Team", + GroupName: "GRB", + DiscussionContent: `banana apple carburetor Let me look into it, ok? @Audrey Abrams!"
`, + Recipients: emailNotificationRecipients, + }, + ) + noErr(err) + err = client.SystemIntake.SendSystemIntakeAdminUploadDocEmail( ctx, email.SendSystemIntakeAdminUploadDocEmailInput{ diff --git a/gqlgen.yml b/gqlgen.yml index 7bd01b1c67..86649adf59 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -84,6 +84,9 @@ models: HTML: model: - github.com/cms-enterprise/easi-app/pkg/models.HTML + TaggedHTML: + model: + - github.com/cms-enterprise/easi-app/pkg/models.TaggedHTML EmailAddress: model: - github.com/cms-enterprise/easi-app/pkg/models.EmailAddress diff --git a/migrations/V195__Add_grb_review_discussions_table.sql b/migrations/V195__Add_grb_review_discussions_table.sql new file mode 100644 index 0000000000..409f657ced --- /dev/null +++ b/migrations/V195__Add_grb_review_discussions_table.sql @@ -0,0 +1,47 @@ +CREATE TABLE IF NOT EXISTS system_intake_internal_grb_review_discussion_posts ( + id UUID PRIMARY KEY NOT NULL, + content TEXT NOT NULL, + voting_role grb_reviewer_voting_role_type, + grb_role grb_reviewer_role_type, + system_intake_id UUID NOT NULL + REFERENCES system_intakes(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + reply_to_id UUID + REFERENCES system_intake_internal_grb_review_discussion_posts (id) + ON DELETE CASCADE + ON UPDATE CASCADE, + created_by UUID NOT NULL REFERENCES user_account(id), + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP, + modified_by UUID REFERENCES user_account(id), + modified_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT is_admin_or_reviewer CHECK ( + (voting_role IS NULL AND grb_role IS NULL) OR + (voting_role IS NOT NULL AND grb_role IS NOT NULL) + ) +); + +CREATE OR REPLACE FUNCTION prevent_nested_replies_fn() RETURNS TRIGGER AS $$ +DECLARE + parent_reply_to_id UUID; +BEGIN + -- If reply_to_id is not NULL, check if it points to a top-level post + IF NEW.reply_to_id IS NOT NULL THEN + -- Fetch the reply_to_id of the parent post + SELECT reply_to_id INTO parent_reply_to_id + FROM system_intake_internal_grb_review_discussion_posts + WHERE id = NEW.reply_to_id; + + -- If parent_reply_to_id is NOT NULL, the parent post is a reply, so raise an error + IF parent_reply_to_id IS NOT NULL THEN + RAISE EXCEPTION 'Replies can only be made to top-level posts'; + END IF; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER prevent_nested_replies_trigger +BEFORE INSERT OR UPDATE ON system_intake_internal_grb_review_discussion_posts +FOR EACH ROW EXECUTE FUNCTION prevent_nested_replies_fn(); diff --git a/package.json b/package.json index 6dde27fbff..54c70c290e 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,14 @@ "@okta/okta-auth-js": "^7.8.0", "@okta/okta-react": "^6.9.0", "@okta/okta-signin-widget": "^7.23.0", + "@tiptap/core": "^2.10.2", + "@tiptap/extension-document": "^2.10.2", + "@tiptap/extension-mention": "^2.9.1", + "@tiptap/extension-paragraph": "^2.10.2", + "@tiptap/extension-text": "^2.10.2", + "@tiptap/pm": "^2.9.1", + "@tiptap/react": "^2.9.1", + "@tiptap/suggestion": "^2.9.1", "@toast-ui/react-editor": "^3.2.3", "@trussworks/react-uswds": "^3.2.0", "@types/apollo-upload-client": "^17.0.2", @@ -75,6 +83,7 @@ "redux-devtools-extension": "^2.13.9", "redux-saga": "^1.3.0", "redux-saga-routines": "^3.2.2", + "tippy.js": "^6.3.7", "typescript": "^4.9.5", "uuid": "^8.3.2", "wildcard-mock-link": "^2.0.3", @@ -179,7 +188,8 @@ "node-fetch": "2.6.1", "graphql": "15.8.0", "@graphql-typed-document-node/core": "3.2.0", - "@apollo/federation": "0.38.1" + "@apollo/federation": "0.38.1", + "prosemirror-model": "1.23.0" }, "comments": { "on_resolutions": { diff --git a/pkg/dataloaders/dataloaders.go b/pkg/dataloaders/dataloaders.go index 493cfd60c9..d9ba99d2b4 100644 --- a/pkg/dataloaders/dataloaders.go +++ b/pkg/dataloaders/dataloaders.go @@ -78,6 +78,7 @@ type Dataloaders struct { SystemIntakeFundingSources *dataloadgen.Loader[uuid.UUID, []*models.SystemIntakeFundingSource] SystemIntakeGovReqFeedback *dataloadgen.Loader[uuid.UUID, []*models.GovernanceRequestFeedback] SystemIntakeGRBReviewers *dataloadgen.Loader[uuid.UUID, []*models.SystemIntakeGRBReviewer] + SystemIntakeGRBDiscussionPosts *dataloadgen.Loader[uuid.UUID, []*models.SystemIntakeGRBReviewDiscussionPost] SystemIntakeNotes *dataloadgen.Loader[uuid.UUID, []*models.SystemIntakeNote] SystemIntakeRelatedSystemIntakes *dataloadgen.Loader[uuid.UUID, []*models.SystemIntake] SystemIntakeRelatedTRBRequests *dataloadgen.Loader[uuid.UUID, []*models.TRBRequest] @@ -120,6 +121,7 @@ func NewDataloaders(store *storage.Store, fetchUserInfos fetchUserInfosFunc, get SystemIntakeFundingSources: dataloadgen.NewLoader(dr.batchSystemIntakeFundingSourcesBySystemIntakeIDs), SystemIntakeGovReqFeedback: dataloadgen.NewLoader(dr.batchSystemIntakeGovReqFeedbackByIntakeIDs), SystemIntakeGRBReviewers: dataloadgen.NewLoader(dr.batchSystemIntakeGRBReviewersBySystemIntakeIDs), + SystemIntakeGRBDiscussionPosts: dataloadgen.NewLoader(dr.batchSystemIntakeGRBDiscussionPostsBySystemIntakeIDs), SystemIntakeNotes: dataloadgen.NewLoader(dr.batchSystemIntakeNotesBySystemIntakeIDs), SystemIntakeRelatedSystemIntakes: dataloadgen.NewLoader(dr.batchRelatedSystemIntakesBySystemIntakeIDs), SystemIntakeRelatedTRBRequests: dataloadgen.NewLoader(dr.batchRelatedTRBRequestsBySystemIntakeIDs), diff --git a/pkg/dataloaders/system_intake_grb_discussion_posts.go b/pkg/dataloaders/system_intake_grb_discussion_posts.go new file mode 100644 index 0000000000..811d7daf25 --- /dev/null +++ b/pkg/dataloaders/system_intake_grb_discussion_posts.go @@ -0,0 +1,29 @@ +package dataloaders + +import ( + "context" + "errors" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/helpers" + "github.com/cms-enterprise/easi-app/pkg/models" +) + +func (d *dataReader) batchSystemIntakeGRBDiscussionPostsBySystemIntakeIDs(ctx context.Context, systemIntakeIDs []uuid.UUID) ([][]*models.SystemIntakeGRBReviewDiscussionPost, []error) { + data, err := d.db.SystemIntakeGRBDiscussionPostsBySystemIntakeIDs(ctx, systemIntakeIDs) + if err != nil { + return nil, []error{err} + } + + return helpers.OneToMany(systemIntakeIDs, data), nil +} + +func GetSystemIntakeGRBDiscussionPostsBySystemIntakeID(ctx context.Context, systemIntakeID uuid.UUID) ([]*models.SystemIntakeGRBReviewDiscussionPost, error) { + loaders, ok := loadersFromCTX(ctx) + if !ok { + return nil, errors.New("unexpected nil loaders in GetSystemIntakeGRBReviewersBySystemIntakeID") + } + + return loaders.SystemIntakeGRBDiscussionPosts.Load(ctx, systemIntakeID) +} diff --git a/pkg/email/email.go b/pkg/email/email.go index 8bef30574a..878412ee21 100644 --- a/pkg/email/email.go +++ b/pkg/email/email.go @@ -76,6 +76,9 @@ type templates struct { systemIntakeUpdateLCID templateCaller systemIntakeChangeLCIDRetirementDate templateCaller systemIntakeCreateGRBReviewer templateCaller + grbReviewDiscussionReply templateCaller + grbReviewDiscussionIndividualTagged templateCaller + grbReviewDiscussionGroupTagged templateCaller } // sender is an interface for swapping out email provider implementations @@ -405,6 +408,27 @@ func NewClient(config Config, sender sender) (Client, error) { } appTemplates.systemIntakeCreateGRBReviewer = systemIntakeCreateGRBReviewer + grbReviewDiscussionReplyTemplateName := "grb_review_discussion_reply.gohtml" + grbReviewDiscussionReply := rawTemplates.Lookup(grbReviewDiscussionReplyTemplateName) + if grbReviewDiscussionReply == nil { + return Client{}, templateError(grbReviewDiscussionReplyTemplateName) + } + appTemplates.grbReviewDiscussionReply = grbReviewDiscussionReply + + grbReviewDiscussionIndividualTaggedTemplateName := "grb_review_discussion_individual_tagged.gohtml" + grbReviewDiscussionIndividualTagged := rawTemplates.Lookup(grbReviewDiscussionIndividualTaggedTemplateName) + if grbReviewDiscussionIndividualTagged == nil { + return Client{}, templateError(grbReviewDiscussionIndividualTaggedTemplateName) + } + appTemplates.grbReviewDiscussionIndividualTagged = grbReviewDiscussionIndividualTagged + + grbReviewDiscussionGroupTaggedTemplateName := "grb_review_discussion_group_tagged.gohtml" + grbReviewDiscussionGroupTagged := rawTemplates.Lookup(grbReviewDiscussionGroupTaggedTemplateName) + if grbReviewDiscussionGroupTagged == nil { + return Client{}, templateError(grbReviewDiscussionGroupTaggedTemplateName) + } + appTemplates.grbReviewDiscussionGroupTagged = grbReviewDiscussionGroupTagged + client := Client{ config: config, templates: appTemplates, @@ -424,6 +448,19 @@ func (c Client) urlFromPath(path string) string { Host: c.config.URLHost, Path: path, } + + return u.String() +} + +// urlFromPathAndQuery uses the client's URL configs to format one with a specific path and appended query +func (c Client) urlFromPathAndQuery(path string, query string) string { + u := url.URL{ + Scheme: c.config.URLScheme, + Host: c.config.URLHost, + Path: path, + RawQuery: query, + } + return u.String() } @@ -469,6 +506,9 @@ func HumanizeSnakeCase(s string) string { return strings.Join(wordSlice, " ") } +// Email consists of the basic components of an email +// note: only one of `To`/`CC`/`BCC` needed to send an email +// ex: you can send to only BCC recipients if desired type Email struct { ToAddresses []models.EmailAddress CcAddresses []models.EmailAddress @@ -477,30 +517,36 @@ type Email struct { Body string } +// NewEmail returns an empty email object func NewEmail() Email { return Email{} } +// WithToAddresses sets the `To` field on an email func (e Email) WithToAddresses(toAddresses []models.EmailAddress) Email { e.ToAddresses = toAddresses return e } +// WithCCAddresses sets the `CC` field on an email func (e Email) WithCCAddresses(ccAddresses []models.EmailAddress) Email { e.CcAddresses = ccAddresses return e } +// WithBCCAddresses sets the `BCC` field on an email func (e Email) WithBCCAddresses(bccAddresses []models.EmailAddress) Email { e.BccAddresses = bccAddresses return e } +// WithSubject sets the Subject on an email func (e Email) WithSubject(subject string) Email { e.Subject = subject return e } +// WithBody sets the content (body) of an email func (e Email) WithBody(body string) Email { e.Body = body return e diff --git a/pkg/email/grb_review_discussion_group_tagged.go b/pkg/email/grb_review_discussion_group_tagged.go new file mode 100644 index 0000000000..f9412da5d1 --- /dev/null +++ b/pkg/email/grb_review_discussion_group_tagged.go @@ -0,0 +1,97 @@ +package email + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "path" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/models" +) + +// SendGRBReviewDiscussionGroupTaggedEmailInput contains the data needed to send an email informing a group they +// have been tagged in a GRB discussion +type SendGRBReviewDiscussionGroupTaggedEmailInput struct { + SystemIntakeID uuid.UUID + UserName string + GroupName string // TODO NJD enum? + RequestName string + Role string + DiscussionContent template.HTML + DiscussionID uuid.UUID + Recipients models.EmailNotificationRecipients +} + +// GRBReviewDiscussionGroupTaggedBody contains the data needed for interpolation in +// the GRB Discussion Group Tagged email template +type GRBReviewDiscussionGroupTaggedBody struct { + SystemIntakeID uuid.UUID + UserName string + GroupName string + RequestName string + DiscussionBoardType string + GRBReviewLink string + Role string + DiscussionContent template.HTML + DiscussionLink string + ClientAddress string + ITGovernanceInboxAddress models.EmailAddress + IsAdmin bool +} + +func (sie systemIntakeEmails) grbReviewDiscussionGroupTaggedBody(input SendGRBReviewDiscussionGroupTaggedEmailInput) (string, error) { + if sie.client.templates.grbReviewDiscussionGroupTagged == nil { + return "", errors.New("grb review discussion reply template is nil") + } + + grbReviewPath := path.Join("it-governance", input.SystemIntakeID.String(), "grb-review") + + data := GRBReviewDiscussionGroupTaggedBody{ + UserName: input.UserName, + GroupName: input.GroupName, + RequestName: input.RequestName, + DiscussionBoardType: "Internal GRB Discussion Board", + GRBReviewLink: sie.client.urlFromPath(grbReviewPath), + Role: input.Role, + DiscussionContent: input.DiscussionContent, + DiscussionLink: sie.client.urlFromPathAndQuery(grbReviewPath, fmt.Sprintf("discussionMode=reply&discussionId=%s", input.DiscussionID.String())), + ITGovernanceInboxAddress: sie.client.config.GRTEmail, + IsAdmin: input.Role == "Governance Admin Team", + } + + var b bytes.Buffer + if err := sie.client.templates.grbReviewDiscussionGroupTagged.Execute(&b, data); err != nil { + return "", err + } + + return b.String(), nil +} + +// SendGRBReviewDiscussionGroupTaggedEmail sends an email to a group indicating that they have +// been tagged in a GRB discussion +func (sie systemIntakeEmails) SendGRBReviewDiscussionGroupTaggedEmail(ctx context.Context, input SendGRBReviewDiscussionGroupTaggedEmailInput) error { + subject := fmt.Sprintf("The %[1]s was tagged in a GRB Review discussion for %[2]s", input.GroupName, input.RequestName) + + body, err := sie.grbReviewDiscussionGroupTaggedBody(input) + if err != nil { + return err + } + email := NewEmail(). + // use BCC as this is going to multiple recipients + WithBCCAddresses(input.Recipients.RegularRecipientEmails). + WithSubject(subject). + WithBody(body) + + if input.Recipients.ShouldNotifyITGovernance { + email = email.WithCCAddresses([]models.EmailAddress{sie.client.config.GRTEmail}) + } + + return sie.client.sender.Send( + ctx, + email, + ) +} diff --git a/pkg/email/grb_review_discussion_group_tagged_test.go b/pkg/email/grb_review_discussion_group_tagged_test.go new file mode 100644 index 0000000000..aeebb26d65 --- /dev/null +++ b/pkg/email/grb_review_discussion_group_tagged_test.go @@ -0,0 +1,211 @@ +package email + +import ( + "context" + "fmt" + "html/template" + "path" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/models" +) + +func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotification() { + ctx := context.Background() + intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + postID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + userName := "Rock Lee" + groupName := "Governance Review Board" + requestName := "Salad/Sandwich Program" + discussionBoardType := "Internal GRB Discussion Board" + role := "Consumer" + discussionContent := template.HTML(`banana apple carburetor Let me look into it, ok? @Audrey Abrams!"
`) + + sender := mockSender{} + client, err := NewClient(s.config, &sender) + s.NoError(err) + + intakePath := path.Join("it-governance", intakeID.String(), "grb-review") + + grbReviewLink := client.urlFromPath(intakePath) + + discussionLink := client.urlFromPathAndQuery(intakePath, fmt.Sprintf("discussionMode=reply&discussionId=%s", postID.String())) + + ITGovInboxAddress := s.config.GRTEmail.String() + + recipient := models.NewEmailAddress("fake@fake.com") + recipients := models.EmailNotificationRecipients{ + RegularRecipientEmails: []models.EmailAddress{recipient}, + ShouldNotifyITGovernance: false, + ShouldNotifyITInvestment: false, + } + + input := SendGRBReviewDiscussionGroupTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + GroupName: groupName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipients: recipients, + } + + err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail(ctx, input) + s.NoError(err) + + expectedEmail := fmt.Sprintf(` +Easy Access to System Information
+ +%s tagged the %s in the %s for %s.
+ + +Discussion
+%s
+%s
+If you have questions, please contact the Governance Team at %s.
+You will continue to receive email notifications about this request until it is closed.
+ `, + userName, + groupName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + role, + discussionContent, + discussionLink, + ITGovInboxAddress, + ITGovInboxAddress, + ) + + expectedSubject := fmt.Sprintf("The %[1]s was tagged in a GRB Review discussion for %[2]s", groupName, requestName) + + s.Run("Subject is correct", func() { + s.Equal(expectedSubject, sender.subject) + + }) + + s.Run("Recipient is correct", func() { + s.ElementsMatch(sender.bccAddresses, recipients.RegularRecipientEmails) + s.Empty(sender.ccAddresses) + s.Empty(sender.toAddresses) + }) + + s.Run("all info is included", func() { + s.EqualHTML(expectedEmail, sender.body) + }) +} + +func (s *EmailTestSuite) TestCreateGRBReviewDiscussionGroupTaggedNotificationAdmin() { + ctx := context.Background() + intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + postID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + userName := "Rock Lee" + groupName := "Governance Review Board" + requestName := "Salad/Sandwich Program" + discussionBoardType := "Internal GRB Discussion Board" + role := "Governance Admin Team" + discussionContent := template.HTML(`banana apple carburetor Let me look into it, ok? @Audrey Abrams!"
`) + + sender := mockSender{} + client, err := NewClient(s.config, &sender) + s.NoError(err) + + intakePath := path.Join("it-governance", intakeID.String(), "grb-review") + + grbReviewLink := client.urlFromPath(intakePath) + + discussionLink := client.urlFromPathAndQuery(intakePath, fmt.Sprintf("discussionMode=reply&discussionId=%s", postID.String())) + + recipient := models.NewEmailAddress("fake@fake.com") + recipients := models.EmailNotificationRecipients{ + RegularRecipientEmails: []models.EmailAddress{recipient}, + ShouldNotifyITGovernance: false, + ShouldNotifyITInvestment: false, + } + + input := SendGRBReviewDiscussionGroupTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + GroupName: groupName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipients: recipients, + } + + err = client.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail(ctx, input) + s.NoError(err) + + expectedEmail := fmt.Sprintf(` +Easy Access to System Information
+ +%s tagged the %s in the %s for %s.
+ + +Discussion
+%s
+Governance Admin Team
+You will continue to receive email notifications about this request until it is closed.
+ `, + userName, + groupName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + discussionContent, + discussionLink, + ) + + expectedSubject := fmt.Sprintf("The %[1]s was tagged in a GRB Review discussion for %[2]s", groupName, requestName) + + s.Run("Subject is correct", func() { + s.Equal(expectedSubject, sender.subject) + + }) + + s.Run("Recipient is correct", func() { + allRecipients := []models.EmailAddress{ + recipient, + } + s.ElementsMatch(sender.bccAddresses, allRecipients) + s.Empty(sender.toAddresses) + s.Empty(sender.ccAddresses) + }) + + s.Run("all info is included", func() { + s.EqualHTML(expectedEmail, sender.body) + }) +} diff --git a/pkg/email/grb_review_discussion_individual_tagged.go b/pkg/email/grb_review_discussion_individual_tagged.go new file mode 100644 index 0000000000..3371a5720f --- /dev/null +++ b/pkg/email/grb_review_discussion_individual_tagged.go @@ -0,0 +1,86 @@ +package email + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "path" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/models" +) + +// SendGRBReviewDiscussionIndividualTaggedEmailInput contains the data needed to to send an email informing an individual they +// have been tagged in a GRB discussion +type SendGRBReviewDiscussionIndividualTaggedEmailInput struct { + SystemIntakeID uuid.UUID + UserName string + RequestName string + Role string + DiscussionID uuid.UUID + DiscussionContent template.HTML + Recipients []models.EmailAddress +} + +// GRBReviewDiscussionIndividualTaggedBody contains the data needed for interpolation in +// the GRB Discussion Individual Tagged email template +type GRBReviewDiscussionIndividualTaggedBody struct { + SystemIntakeID uuid.UUID + UserName string + RequestName string + DiscussionBoardType string + GRBReviewLink string + Role string + DiscussionContent template.HTML + DiscussionLink string + ITGovernanceInboxAddress models.EmailAddress + IsAdmin bool +} + +func (sie systemIntakeEmails) grbReviewDiscussionIndividualTaggedBody(input SendGRBReviewDiscussionIndividualTaggedEmailInput) (string, error) { + if sie.client.templates.grbReviewDiscussionIndividualTagged == nil { + return "", errors.New("grb review discussion reply template is nil") + } + + grbReviewPath := path.Join("it-governance", input.SystemIntakeID.String(), "grb-review") + + data := GRBReviewDiscussionIndividualTaggedBody{ + UserName: input.UserName, + RequestName: input.RequestName, + DiscussionBoardType: "Internal GRB Discussion Board", + GRBReviewLink: sie.client.urlFromPath(grbReviewPath), + Role: input.Role, + DiscussionContent: input.DiscussionContent, + DiscussionLink: sie.client.urlFromPathAndQuery(grbReviewPath, fmt.Sprintf("discussionMode=reply&discussionId=%s", input.DiscussionID.String())), + ITGovernanceInboxAddress: sie.client.config.GRTEmail, + IsAdmin: input.Role == "Governance Admin Team", + } + + var b bytes.Buffer + if err := sie.client.templates.grbReviewDiscussionIndividualTagged.Execute(&b, data); err != nil { + return "", err + } + + return b.String(), nil +} + +// SendGRBReviewDiscussionIndividualTaggedEmail sends an email to an individual indicating that they have +// been tagged in a GRB discussion +func (sie systemIntakeEmails) SendGRBReviewDiscussionIndividualTaggedEmail(ctx context.Context, input SendGRBReviewDiscussionIndividualTaggedEmailInput) error { + subject := fmt.Sprintf("You were tagged in a GRB Review discussion for %s", input.RequestName) + + body, err := sie.grbReviewDiscussionIndividualTaggedBody(input) + if err != nil { + return err + } + + mail := NewEmail(). + WithBCCAddresses(input.Recipients). + WithSubject(subject). + WithBody(body) + + return sie.client.sender.Send(ctx, mail) +} diff --git a/pkg/email/grb_review_discussion_individual_tagged_test.go b/pkg/email/grb_review_discussion_individual_tagged_test.go new file mode 100644 index 0000000000..55f79ff7ec --- /dev/null +++ b/pkg/email/grb_review_discussion_individual_tagged_test.go @@ -0,0 +1,191 @@ +package email + +import ( + "context" + "fmt" + "html/template" + "path" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/models" +) + +func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotification() { + ctx := context.Background() + intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + postID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + userName := "Rock Lee" + requestName := "Salad/Sandwich Program" + discussionBoardType := "Internal GRB Discussion Board" + role := "Consumer" + discussionContent := template.HTML(`banana apple carburetor Let me look into it, ok? @Audrey Abrams!"
`) + + sender := mockSender{} + client, err := NewClient(s.config, &sender) + s.NoError(err) + + intakePath := path.Join("it-governance", intakeID.String(), "grb-review") + + grbReviewLink := client.urlFromPath(intakePath) + + discussionLink := client.urlFromPathAndQuery(intakePath, fmt.Sprintf("discussionMode=reply&discussionId=%s", postID.String())) + + ITGovInboxAddress := s.config.GRTEmail.String() + + recipients := []models.EmailAddress{"fake@fake.com"} + + input := SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipients: recipients, + } + + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail(ctx, input) + s.NoError(err) + + expectedEmail := fmt.Sprintf(` +Easy Access to System Information
+ +%s tagged you in the %s for %s.
+ + +Discussion
+%s
+%s
+If you have questions, please contact the Governance Team at %s.
+You will continue to receive email notifications about this request until it is closed.
+ `, + userName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + role, + discussionContent, + discussionLink, + ITGovInboxAddress, + ITGovInboxAddress, + ) + + expectedSubject := fmt.Sprintf("You were tagged in a GRB Review discussion for %s", requestName) + + s.Run("Subject is correct", func() { + s.Equal(expectedSubject, sender.subject) + + }) + + s.Run("Recipient is correct", func() { + s.ElementsMatch(sender.bccAddresses, recipients) + s.Empty(sender.ccAddresses) + s.Empty(sender.toAddresses) + }) + + s.Run("all info is included", func() { + s.EqualHTML(expectedEmail, sender.body) + }) +} + +func (s *EmailTestSuite) TestCreateGRBReviewDiscussionIndividualTaggedNotificationAdmin() { + ctx := context.Background() + intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + postID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + userName := "Rock Lee" + requestName := "Salad/Sandwich Program" + discussionBoardType := "Internal GRB Discussion Board" + role := "Governance Admin Team" + discussionContent := template.HTML(`banana apple carburetor Let me look into it, ok? @Audrey Abrams!"
`) + + sender := mockSender{} + client, err := NewClient(s.config, &sender) + s.NoError(err) + + intakePath := path.Join("it-governance", intakeID.String(), "grb-review") + + grbReviewLink := client.urlFromPath(intakePath) + + discussionLink := client.urlFromPathAndQuery(intakePath, fmt.Sprintf("discussionMode=reply&discussionId=%s", postID.String())) + + recipients := []models.EmailAddress{"fake@fake.com"} + + input := SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipients: recipients, + } + + err = client.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail(ctx, input) + s.NoError(err) + + expectedEmail := fmt.Sprintf(` +Easy Access to System Information
+ +%s tagged you in the %s for %s.
+ + +Discussion
+%s
+Governance Admin Team
+You will continue to receive email notifications about this request until it is closed.
+ `, + userName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + discussionContent, + discussionLink, + ) + + expectedSubject := fmt.Sprintf("You were tagged in a GRB Review discussion for %s", requestName) + + s.Run("Subject is correct", func() { + s.Equal(expectedSubject, sender.subject) + }) + + s.Run("Recipient is correct", func() { + s.Equal(sender.bccAddresses[0], recipients[0]) + s.Empty(sender.ccAddresses) + s.Empty(sender.toAddresses) + }) + + s.Run("all info is included", func() { + s.EqualHTML(expectedEmail, sender.body) + }) +} diff --git a/pkg/email/grb_review_discussion_reply.go b/pkg/email/grb_review_discussion_reply.go new file mode 100644 index 0000000000..b8cc2954a0 --- /dev/null +++ b/pkg/email/grb_review_discussion_reply.go @@ -0,0 +1,85 @@ +package email + +import ( + "bytes" + "context" + "errors" + "fmt" + "html/template" + "path" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/models" +) + +// SendGRBReviewDiscussionReplyEmailInput contains the data needed to send the GRB discussion reply email +type SendGRBReviewDiscussionReplyEmailInput struct { + SystemIntakeID uuid.UUID + UserName string + RequestName string + Role string + DiscussionID uuid.UUID + DiscussionContent template.HTML + Recipient models.EmailAddress +} + +// GRBReviewDiscussionReplyBody contains the data needed for interpolation in +// the TRB advice letter submitted email template +type GRBReviewDiscussionReplyBody struct { + UserName string + RequestName string + DiscussionBoardType string + GRBReviewLink string + Role string + DiscussionContent template.HTML + DiscussionLink string + ITGovernanceInboxAddress models.EmailAddress + IsAdmin bool +} + +func (sie systemIntakeEmails) grbReviewDiscussionReplyBody(input SendGRBReviewDiscussionReplyEmailInput) (string, error) { + if sie.client.templates.grbReviewDiscussionReply == nil { + return "", errors.New("grb review discussion reply template is nil") + } + + grbReviewPath := path.Join("it-governance", input.SystemIntakeID.String(), "grb-review") + + data := GRBReviewDiscussionReplyBody{ + UserName: input.UserName, + RequestName: input.RequestName, + DiscussionBoardType: "Internal GRB Discussion Board", + GRBReviewLink: sie.client.urlFromPath(grbReviewPath), + Role: input.Role, + DiscussionContent: input.DiscussionContent, + DiscussionLink: sie.client.urlFromPathAndQuery(grbReviewPath, fmt.Sprintf("discussionMode=reply&discussionId=%s", input.DiscussionID.String())), + ITGovernanceInboxAddress: sie.client.config.GRTEmail, + IsAdmin: input.Role == "Governance Admin Team", + } + + var b bytes.Buffer + if err := sie.client.templates.grbReviewDiscussionReply.Execute(&b, data); err != nil { + return "", err + } + + return b.String(), nil +} + +// SendGRBReviewDiscussionReplyEmail sends an email to the EASI admin team indicating that an advice letter +// has been submitted +func (sie systemIntakeEmails) SendGRBReviewDiscussionReplyEmail(ctx context.Context, input SendGRBReviewDiscussionReplyEmailInput) error { + subject := fmt.Sprintf("New reply to your discussion in the GRB Review for %s", input.RequestName) + + body, err := sie.grbReviewDiscussionReplyBody(input) + if err != nil { + return err + } + + return sie.client.sender.Send( + ctx, + NewEmail(). + WithToAddresses([]models.EmailAddress{input.Recipient}). + WithSubject(subject). + WithBody(body), + ) +} diff --git a/pkg/email/grb_review_discussion_reply_test.go b/pkg/email/grb_review_discussion_reply_test.go new file mode 100644 index 0000000000..fd08409387 --- /dev/null +++ b/pkg/email/grb_review_discussion_reply_test.go @@ -0,0 +1,198 @@ +package email + +import ( + "context" + "fmt" + "html/template" + "path" + + "github.com/google/uuid" + + "github.com/cms-enterprise/easi-app/pkg/models" +) + +func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotification() { + ctx := context.Background() + intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + postID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + userName := "Rock Lee" + requestName := "Salad/Sandwich Program" + discussionBoardType := "Internal GRB Discussion Board" + role := "Consumer" + discussionContent := template.HTML(`banana apple carburetor Let me look into it, ok? @Audrey Abrams!"
`) + + sender := mockSender{} + client, err := NewClient(s.config, &sender) + s.NoError(err) + + intakePath := path.Join("it-governance", intakeID.String(), "grb-review") + + grbReviewLink := client.urlFromPath(intakePath) + + discussionLink := client.urlFromPathAndQuery(intakePath, fmt.Sprintf("discussionMode=reply&discussionId=%s", postID.String())) + + ITGovInboxAddress := s.config.GRTEmail.String() + + recipient := models.NewEmailAddress("fake@fake.com") + + input := SendGRBReviewDiscussionReplyEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipient: recipient, + } + + err = client.SystemIntake.SendGRBReviewDiscussionReplyEmail(ctx, input) + s.NoError(err) + + expectedEmail := fmt.Sprintf(` +Easy Access to System Information
+ +%s replied to your discussion on the %s for %s.
+ + +Discussion Reply
+%s
+%s
+If you have questions, please contact the Governance Team at %s.
+You will continue to receive email notifications about this request until it is closed.
+ `, + userName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + role, + discussionContent, + discussionLink, + ITGovInboxAddress, + ITGovInboxAddress, + ) + + expectedSubject := fmt.Sprintf("New reply to your discussion in the GRB Review for %s", requestName) + + s.Run("Subject is correct", func() { + s.Equal(expectedSubject, sender.subject) + + }) + + s.Run("Recipient is correct", func() { + allRecipients := []models.EmailAddress{ + recipient, + } + s.ElementsMatch(sender.toAddresses, allRecipients) + s.Empty(sender.ccAddresses) + s.Empty(sender.bccAddresses) + }) + + s.Run("all info is included", func() { + s.EqualHTML(expectedEmail, sender.body) + }) +} + +func (s *EmailTestSuite) TestCreateGRBReviewDiscussionReplyNotificationAdmin() { + ctx := context.Background() + intakeID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + postID := uuid.MustParse("24dd7736-e4c2-4f67-8844-51187de49069") + userName := "Rock Lee" + requestName := "Salad/Sandwich Program" + discussionBoardType := "Internal GRB Discussion Board" + role := "Governance Admin Team" + discussionContent := template.HTML(`banana apple carburetor Let me look into it, ok? @Audrey Abrams!"
`) + + sender := mockSender{} + client, err := NewClient(s.config, &sender) + s.NoError(err) + + intakePath := path.Join("it-governance", intakeID.String(), "grb-review") + + grbReviewLink := client.urlFromPath(intakePath) + + discussionLink := client.urlFromPathAndQuery(intakePath, fmt.Sprintf("discussionMode=reply&discussionId=%s", postID.String())) + + recipient := models.NewEmailAddress("fake@fake.com") + + input := SendGRBReviewDiscussionReplyEmailInput{ + SystemIntakeID: intakeID, + UserName: userName, + RequestName: requestName, + Role: role, + DiscussionID: postID, + DiscussionContent: discussionContent, + Recipient: recipient, + } + + err = client.SystemIntake.SendGRBReviewDiscussionReplyEmail(ctx, input) + s.NoError(err) + + expectedEmail := fmt.Sprintf(` +Easy Access to System Information
+ +%s replied to your discussion on the %s for %s.
+ + +Discussion Reply
+%s
+Governance Admin Team
+You will continue to receive email notifications about this request until it is closed.
+ `, + userName, + discussionBoardType, + requestName, + grbReviewLink, + userName, + discussionContent, + discussionLink, + ) + + expectedSubject := fmt.Sprintf("New reply to your discussion in the GRB Review for %s", requestName) + + s.Run("Subject is correct", func() { + s.Equal(expectedSubject, sender.subject) + + }) + + s.Run("Recipient is correct", func() { + allRecipients := []models.EmailAddress{ + recipient, + } + s.ElementsMatch(sender.toAddresses, allRecipients) + s.Empty(sender.ccAddresses) + s.Empty(sender.bccAddresses) + }) + + s.Run("all info is included", func() { + s.EqualHTML(expectedEmail, sender.body) + }) +} diff --git a/pkg/email/templates/easi_header.gohtml b/pkg/email/templates/easi_header.gohtml index bfb8af5d22..417a182855 100644 --- a/pkg/email/templates/easi_header.gohtml +++ b/pkg/email/templates/easi_header.gohtml @@ -25,14 +25,28 @@ .no-margin-top { margin-top: 0; } - .no-margin-bottom { - margin-bottom: 0; - } hr { border-style: solid; border-color: #bbb; border-radius: 2px; } + + .mention { + color: #005EA2; + } + + .quote>p:first-of-type { + margin-top: 0; + } + .quote>p:first-of-type::before { + content: '\201C'; + } + .quote>p:last-of-type { + margin-bottom: 0; + } + .quote>p:last-of-type::after { + content: '\201D'; + } diff --git a/pkg/email/templates/grb_review_discussion_group_tagged.gohtml b/pkg/email/templates/grb_review_discussion_group_tagged.gohtml new file mode 100644 index 0000000000..503fe135b5 --- /dev/null +++ b/pkg/email/templates/grb_review_discussion_group_tagged.gohtml @@ -0,0 +1,30 @@ +{{template "easi_header.gohtml"}} + +{{.UserName}} tagged the {{.GroupName}} in the {{.DiscussionBoardType}} for {{.RequestName}}.
+ + + +Discussion
+ +{{.UserName}}
+{{.Role}}
+ +If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.
+{{end}} +You will continue to receive email notifications about this request until it is closed.
diff --git a/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml b/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml new file mode 100644 index 0000000000..05e484b077 --- /dev/null +++ b/pkg/email/templates/grb_review_discussion_individual_tagged.gohtml @@ -0,0 +1,30 @@ +{{template "easi_header.gohtml"}} + +{{.UserName}} tagged you in the {{.DiscussionBoardType}} for {{.RequestName}}.
+ + + +Discussion
+ +{{.UserName}}
+{{.Role}}
+ +If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.
+{{end}} +You will continue to receive email notifications about this request until it is closed.
diff --git a/pkg/email/templates/grb_review_discussion_reply.gohtml b/pkg/email/templates/grb_review_discussion_reply.gohtml new file mode 100644 index 0000000000..3dd18c1384 --- /dev/null +++ b/pkg/email/templates/grb_review_discussion_reply.gohtml @@ -0,0 +1,30 @@ +{{template "easi_header.gohtml"}} + +{{.UserName}} replied to your discussion on the {{.DiscussionBoardType}} for {{.RequestName}}.
+ + + +Discussion Reply
+ +{{.UserName}}
+{{.Role}}
+ +If you have questions, please contact the Governance Team at {{.ITGovernanceInboxAddress}}.
+{{end}} +You will continue to receive email notifications about this request until it is closed.
diff --git a/pkg/email/templates/system_intake_admin_upload_doc.gohtml b/pkg/email/templates/system_intake_admin_upload_doc.gohtml index ce6ea27a8e..bd089f5948 100644 --- a/pkg/email/templates/system_intake_admin_upload_doc.gohtml +++ b/pkg/email/templates/system_intake_admin_upload_doc.gohtml @@ -10,8 +10,7 @@Request Summary
Project title: {{.RequestName}}
-Requester: {{.RequesterName}}, {{.RequestComponent}} -
+Requester: {{.RequesterName}}, {{.RequestComponent}}
Notification email """ scalar HTML +""" +TaggedHTML is represented using strings but can contain Tags (ex: @User) and possibly other richer elements than HTML +""" +scalar TaggedHTML """ Time values are represented as strings using RFC3339 format, for example 2019-10-12T07:20:50.52Z @@ -11518,6 +11698,70 @@ func (ec *executionContext) field_Mutation_createSystemIntakeDocument_argsInput( return zeroVal, nil } +func (ec *executionContext) field_Mutation_createSystemIntakeGRBDiscussionPost_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + arg0, err := ec.field_Mutation_createSystemIntakeGRBDiscussionPost_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_createSystemIntakeGRBDiscussionPost_argsInput( + ctx context.Context, + rawArgs map[string]interface{}, +) (models.CreateSystemIntakeGRBDiscussionPostInput, error) { + // We won't call the directive if the argument is null. + // Set call_argument_directives_with_null to true to call directives + // even if the argument is null. + _, ok := rawArgs["input"] + if !ok { + var zeroVal models.CreateSystemIntakeGRBDiscussionPostInput + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNcreateSystemIntakeGRBDiscussionPostInput2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐCreateSystemIntakeGRBDiscussionPostInput(ctx, tmp) + } + + var zeroVal models.CreateSystemIntakeGRBDiscussionPostInput + return zeroVal, nil +} + +func (ec *executionContext) field_Mutation_createSystemIntakeGRBDiscussionReply_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + arg0, err := ec.field_Mutation_createSystemIntakeGRBDiscussionReply_argsInput(ctx, rawArgs) + if err != nil { + return nil, err + } + args["input"] = arg0 + return args, nil +} +func (ec *executionContext) field_Mutation_createSystemIntakeGRBDiscussionReply_argsInput( + ctx context.Context, + rawArgs map[string]interface{}, +) (models.CreateSystemIntakeGRBDiscussionReplyInput, error) { + // We won't call the directive if the argument is null. + // Set call_argument_directives_with_null to true to call directives + // even if the argument is null. + _, ok := rawArgs["input"] + if !ok { + var zeroVal models.CreateSystemIntakeGRBDiscussionReplyInput + return zeroVal, nil + } + + ctx = graphql.WithPathContext(ctx, graphql.NewPathWithField("input")) + if tmp, ok := rawArgs["input"]; ok { + return ec.unmarshalNcreateSystemIntakeGRBDiscussionReplyInput2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐCreateSystemIntakeGRBDiscussionReplyInput(ctx, tmp) + } + + var zeroVal models.CreateSystemIntakeGRBDiscussionReplyInput + return zeroVal, nil +} + func (ec *executionContext) field_Mutation_createSystemIntakeGRBReviewers_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -15797,6 +16041,8 @@ func (ec *executionContext) fieldContext_BusinessCase_systemIntake(_ context.Con return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -24687,6 +24933,8 @@ func (ec *executionContext) fieldContext_CedarSystem_linkedSystemIntakes(ctx con return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -31587,6 +31835,8 @@ func (ec *executionContext) fieldContext_Mutation_createSystemIntake(ctx context return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -31827,6 +32077,8 @@ func (ec *executionContext) fieldContext_Mutation_updateSystemIntakeRequestType( return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -32997,6 +33249,150 @@ func (ec *executionContext) fieldContext_Mutation_deleteSystemIntakeGRBReviewer( return fc, nil } +func (ec *executionContext) _Mutation_createSystemIntakeGRBDiscussionPost(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_createSystemIntakeGRBDiscussionPost(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.Mutation().CreateSystemIntakeGRBDiscussionPost(rctx, fc.Args["input"].(models.CreateSystemIntakeGRBDiscussionPostInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*models.SystemIntakeGRBReviewDiscussionPost) + fc.Result = res + return ec.marshalOSystemIntakeGRBReviewDiscussionPost2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPost(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_createSystemIntakeGRBDiscussionPost(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + 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_SystemIntakeGRBReviewDiscussionPost_id(ctx, field) + case "content": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_content(ctx, field) + case "votingRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field) + case "grbRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_grbRole(ctx, field) + case "systemIntakeID": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx, field) + case "createdByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx, field) + case "createdAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdAt(ctx, field) + case "modifiedByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(ctx, field) + case "modifiedAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SystemIntakeGRBReviewDiscussionPost", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_createSystemIntakeGRBDiscussionPost_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + +func (ec *executionContext) _Mutation_createSystemIntakeGRBDiscussionReply(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_Mutation_createSystemIntakeGRBDiscussionReply(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.Mutation().CreateSystemIntakeGRBDiscussionReply(rctx, fc.Args["input"].(models.CreateSystemIntakeGRBDiscussionReplyInput)) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*models.SystemIntakeGRBReviewDiscussionPost) + fc.Result = res + return ec.marshalOSystemIntakeGRBReviewDiscussionPost2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPost(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_Mutation_createSystemIntakeGRBDiscussionReply(ctx context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "Mutation", + 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_SystemIntakeGRBReviewDiscussionPost_id(ctx, field) + case "content": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_content(ctx, field) + case "votingRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field) + case "grbRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_grbRole(ctx, field) + case "systemIntakeID": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx, field) + case "createdByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx, field) + case "createdAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdAt(ctx, field) + case "modifiedByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(ctx, field) + case "modifiedAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SystemIntakeGRBReviewDiscussionPost", field.Name) + }, + } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Mutation_createSystemIntakeGRBDiscussionReply_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return fc, err + } + return fc, nil +} + func (ec *executionContext) _Mutation_updateSystemIntakeLinkedCedarSystem(ctx context.Context, field graphql.CollectedField) (ret graphql.Marshaler) { fc, err := ec.fieldContext_Mutation_updateSystemIntakeLinkedCedarSystem(ctx, field) if err != nil { @@ -33250,6 +33646,8 @@ func (ec *executionContext) fieldContext_Mutation_archiveSystemIntake(ctx contex return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -37462,6 +37860,8 @@ func (ec *executionContext) fieldContext_Query_systemIntake(ctx context.Context, return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -37675,6 +38075,8 @@ func (ec *executionContext) fieldContext_Query_systemIntakes(ctx context.Context return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -37888,6 +38290,8 @@ func (ec *executionContext) fieldContext_Query_mySystemIntakes(_ context.Context return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -38090,6 +38494,8 @@ func (ec *executionContext) fieldContext_Query_systemIntakesWithReviewRequested( return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -38292,6 +38698,8 @@ func (ec *executionContext) fieldContext_Query_systemIntakesWithLcids(_ context. return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -44285,6 +44693,8 @@ func (ec *executionContext) fieldContext_SystemIntake_relatedIntakes(_ context.C return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -44400,6 +44810,56 @@ func (ec *executionContext) fieldContext_SystemIntake_relatedTRBRequests(_ conte return fc, nil } +func (ec *executionContext) _SystemIntake_grbDiscussions(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntake) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntake_grbDiscussions(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.SystemIntake().GrbDiscussions(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.SystemIntakeGRBReviewDiscussion) + fc.Result = res + return ec.marshalNSystemIntakeGRBReviewDiscussion2ᚕᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntake_grbDiscussions(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntake", + Field: field, + IsMethod: true, + IsResolver: true, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "initialPost": + return ec.fieldContext_SystemIntakeGRBReviewDiscussion_initialPost(ctx, field) + case "replies": + return ec.fieldContext_SystemIntakeGRBReviewDiscussion_replies(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SystemIntakeGRBReviewDiscussion", field.Name) + }, + } + return fc, nil +} + func (ec *executionContext) _SystemIntakeAction_id(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeAction) (ret graphql.Marshaler) { fc, err := ec.fieldContext_SystemIntakeAction_id(ctx, field) if err != nil { @@ -44639,6 +45099,8 @@ func (ec *executionContext) fieldContext_SystemIntakeAction_systemIntake(_ conte return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -47150,6 +47612,558 @@ func (ec *executionContext) fieldContext_SystemIntakeFundingSource_source(_ cont return fc, nil } +func (ec *executionContext) _SystemIntakeGRBReviewDiscussion_initialPost(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussion) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussion_initialPost(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.InitialPost, 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.SystemIntakeGRBReviewDiscussionPost) + fc.Result = res + return ec.marshalNSystemIntakeGRBReviewDiscussionPost2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPost(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussion_initialPost(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussion", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_id(ctx, field) + case "content": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_content(ctx, field) + case "votingRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field) + case "grbRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_grbRole(ctx, field) + case "systemIntakeID": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx, field) + case "createdByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx, field) + case "createdAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdAt(ctx, field) + case "modifiedByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(ctx, field) + case "modifiedAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SystemIntakeGRBReviewDiscussionPost", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussion_replies(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussion) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussion_replies(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.Replies, 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.SystemIntakeGRBReviewDiscussionPost) + fc.Result = res + return ec.marshalNSystemIntakeGRBReviewDiscussionPost2ᚕᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPostᚄ(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussion_replies(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussion", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_id(ctx, field) + case "content": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_content(ctx, field) + case "votingRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field) + case "grbRole": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_grbRole(ctx, field) + case "systemIntakeID": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx, field) + case "createdByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx, field) + case "createdAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdAt(ctx, field) + case "modifiedByUserAccount": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(ctx, field) + case "modifiedAt": + return ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedAt(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type SystemIntakeGRBReviewDiscussionPost", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_id(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_id(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.ID, 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_SystemIntakeGRBReviewDiscussionPost_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + 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) _SystemIntakeGRBReviewDiscussionPost_content(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_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.(models.HTML) + fc.Result = res + return ec.marshalNHTML2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐHTML(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_content(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type HTML does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_votingRole(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_votingRole(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.VotingRole, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*models.SystemIntakeGRBReviewerVotingRole) + fc.Result = res + return ec.marshalOSystemIntakeGRBReviewerVotingRole2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewerVotingRole(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_votingRole(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type SystemIntakeGRBReviewerVotingRole does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_grbRole(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_grbRole(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.GRBRole, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*models.SystemIntakeGRBReviewerRole) + fc.Result = res + return ec.marshalOSystemIntakeGRBReviewerRole2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewerRole(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_grbRole(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type SystemIntakeGRBReviewerRole does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_systemIntakeID(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.SystemIntakeID, 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_SystemIntakeGRBReviewDiscussionPost_systemIntakeID(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + 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) _SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(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.CreatedByUserAccount(ctx) + }) + 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.(*authentication.UserAccount) + fc.Result = res + return ec.marshalNUserAccount2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋauthenticationᚐUserAccount(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_UserAccount_id(ctx, field) + case "username": + return ec.fieldContext_UserAccount_username(ctx, field) + case "commonName": + return ec.fieldContext_UserAccount_commonName(ctx, field) + case "locale": + return ec.fieldContext_UserAccount_locale(ctx, field) + case "email": + return ec.fieldContext_UserAccount_email(ctx, field) + case "givenName": + return ec.fieldContext_UserAccount_givenName(ctx, field) + case "familyName": + return ec.fieldContext_UserAccount_familyName(ctx, field) + case "zoneInfo": + return ec.fieldContext_UserAccount_zoneInfo(ctx, field) + case "hasLoggedIn": + return ec.fieldContext_UserAccount_hasLoggedIn(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type UserAccount", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_createdAt(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_createdAt(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.CreatedAt, 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.(time.Time) + fc.Result = res + return ec.marshalNTime2timeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Time does not have child fields") + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(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.ModifiedByUserAccount(ctx) + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*authentication.UserAccount) + fc.Result = res + return ec.marshalOUserAccount2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋauthenticationᚐUserAccount(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: true, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + switch field.Name { + case "id": + return ec.fieldContext_UserAccount_id(ctx, field) + case "username": + return ec.fieldContext_UserAccount_username(ctx, field) + case "commonName": + return ec.fieldContext_UserAccount_commonName(ctx, field) + case "locale": + return ec.fieldContext_UserAccount_locale(ctx, field) + case "email": + return ec.fieldContext_UserAccount_email(ctx, field) + case "givenName": + return ec.fieldContext_UserAccount_givenName(ctx, field) + case "familyName": + return ec.fieldContext_UserAccount_familyName(ctx, field) + case "zoneInfo": + return ec.fieldContext_UserAccount_zoneInfo(ctx, field) + case "hasLoggedIn": + return ec.fieldContext_UserAccount_hasLoggedIn(ctx, field) + } + return nil, fmt.Errorf("no field named %q was found under type UserAccount", field.Name) + }, + } + return fc, nil +} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost_modifiedAt(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewDiscussionPost) (ret graphql.Marshaler) { + fc, err := ec.fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedAt(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.ModifiedAt, nil + }) + if err != nil { + ec.Error(ctx, err) + return graphql.Null + } + if resTmp == nil { + return graphql.Null + } + res := resTmp.(*time.Time) + fc.Result = res + return ec.marshalOTime2ᚖtimeᚐTime(ctx, field.Selections, res) +} + +func (ec *executionContext) fieldContext_SystemIntakeGRBReviewDiscussionPost_modifiedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) { + fc = &graphql.FieldContext{ + Object: "SystemIntakeGRBReviewDiscussionPost", + Field: field, + IsMethod: false, + IsResolver: false, + Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) { + return nil, errors.New("field of type Time does not have child fields") + }, + } + return fc, nil +} + func (ec *executionContext) _SystemIntakeGRBReviewer_id(ctx context.Context, field graphql.CollectedField, obj *models.SystemIntakeGRBReviewer) (ret graphql.Marshaler) { fc, err := ec.fieldContext_SystemIntakeGRBReviewer_id(ctx, field) if err != nil { @@ -52843,6 +53857,8 @@ func (ec *executionContext) fieldContext_TRBRequest_relatedIntakes(_ context.Con return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -55710,6 +56726,8 @@ func (ec *executionContext) fieldContext_TRBRequestForm_systemIntakes(_ context. return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -56466,6 +57484,8 @@ func (ec *executionContext) fieldContext_UpdateSystemIntakePayload_systemIntake( return ec.fieldContext_SystemIntake_relatedIntakes(ctx, field) case "relatedTRBRequests": return ec.fieldContext_SystemIntake_relatedTRBRequests(ctx, field) + case "grbDiscussions": + return ec.fieldContext_SystemIntake_grbDiscussions(ctx, field) } return nil, fmt.Errorf("no field named %q was found under type SystemIntake", field.Name) }, @@ -62780,6 +63800,74 @@ func (ec *executionContext) unmarshalInputUpdateTRBRequestTRBLeadInput(ctx conte return it, nil } +func (ec *executionContext) unmarshalInputcreateSystemIntakeGRBDiscussionPostInput(ctx context.Context, obj interface{}) (models.CreateSystemIntakeGRBDiscussionPostInput, error) { + var it models.CreateSystemIntakeGRBDiscussionPostInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"systemIntakeID", "content"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "systemIntakeID": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("systemIntakeID")) + data, err := ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + if err != nil { + return it, err + } + it.SystemIntakeID = data + case "content": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("content")) + data, err := ec.unmarshalNTaggedHTML2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐTaggedHTML(ctx, v) + if err != nil { + return it, err + } + it.Content = data + } + } + + return it, nil +} + +func (ec *executionContext) unmarshalInputcreateSystemIntakeGRBDiscussionReplyInput(ctx context.Context, obj interface{}) (models.CreateSystemIntakeGRBDiscussionReplyInput, error) { + var it models.CreateSystemIntakeGRBDiscussionReplyInput + asMap := map[string]interface{}{} + for k, v := range obj.(map[string]interface{}) { + asMap[k] = v + } + + fieldsInOrder := [...]string{"initialPostID", "content"} + for _, k := range fieldsInOrder { + v, ok := asMap[k] + if !ok { + continue + } + switch k { + case "initialPostID": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("initialPostID")) + data, err := ec.unmarshalNUUID2githubᚗcomᚋgoogleᚋuuidᚐUUID(ctx, v) + if err != nil { + return it, err + } + it.InitialPostID = data + case "content": + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("content")) + data, err := ec.unmarshalNTaggedHTML2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐTaggedHTML(ctx, v) + if err != nil { + return it, err + } + it.Content = data + } + } + + return it, nil +} + // endregion **************************** input.gotpl ***************************** // region ************************** interface.gotpl *************************** @@ -66015,6 +67103,14 @@ func (ec *executionContext) _Mutation(ctx context.Context, sel ast.SelectionSet) if out.Values[i] == graphql.Null { out.Invalids++ } + case "createSystemIntakeGRBDiscussionPost": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_createSystemIntakeGRBDiscussionPost(ctx, field) + }) + case "createSystemIntakeGRBDiscussionReply": + out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { + return ec._Mutation_createSystemIntakeGRBDiscussionReply(ctx, field) + }) case "updateSystemIntakeLinkedCedarSystem": out.Values[i] = ec.OperationContext.RootResolverMiddleware(innerCtx, func(ctx context.Context) (res graphql.Marshaler) { return ec._Mutation_updateSystemIntakeLinkedCedarSystem(ctx, field) @@ -67477,7 +68573,156 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "nextMeetingDate": + case "nextMeetingDate": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._SystemIntake_nextMeetingDate(ctx, field, obj) + 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 "grbReviewStartedAt": + out.Values[i] = ec._SystemIntake_grbReviewStartedAt(ctx, field, obj) + case "grbReviewers": + 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._SystemIntake_grbReviewers(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 "id": + out.Values[i] = ec._SystemIntake_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "isso": + 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._SystemIntake_isso(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 "lcid": + field := field + + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._SystemIntake_lcid(ctx, field, obj) + 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 "lcidIssuedAt": + out.Values[i] = ec._SystemIntake_lcidIssuedAt(ctx, field, obj) + case "lcidExpiresAt": + out.Values[i] = ec._SystemIntake_lcidExpiresAt(ctx, field, obj) + case "lcidScope": field := field innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { @@ -67486,7 +68731,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_nextMeetingDate(ctx, field, obj) + res = ec._SystemIntake_lcidScope(ctx, field, obj) return res } @@ -67510,21 +68755,16 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "grbReviewStartedAt": - out.Values[i] = ec._SystemIntake_grbReviewStartedAt(ctx, field, obj) - case "grbReviewers": + case "lcidCostBaseline": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_grbReviewers(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } + res = ec._SystemIntake_lcidCostBaseline(ctx, field, obj) return res } @@ -67548,24 +68788,18 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "id": - out.Values[i] = ec._SystemIntake_id(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } - case "isso": + case "lcidRetiresAt": + out.Values[i] = ec._SystemIntake_lcidRetiresAt(ctx, field, obj) + case "needsEaSupport": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_isso(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } + res = ec._SystemIntake_needsEaSupport(ctx, field, obj) return res } @@ -67589,16 +68823,21 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "lcid": + case "usingSoftware": + out.Values[i] = ec._SystemIntake_usingSoftware(ctx, field, obj) + case "acquisitionMethods": field := field - innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + 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._SystemIntake_lcid(ctx, field, obj) + res = ec._SystemIntake_acquisitionMethods(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } return res } @@ -67622,20 +68861,19 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "lcidIssuedAt": - out.Values[i] = ec._SystemIntake_lcidIssuedAt(ctx, field, obj) - case "lcidExpiresAt": - out.Values[i] = ec._SystemIntake_lcidExpiresAt(ctx, field, obj) - case "lcidScope": + case "notes": field := field - innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + 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._SystemIntake_lcidScope(ctx, field, obj) + res = ec._SystemIntake_notes(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } return res } @@ -67659,16 +68897,23 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "lcidCostBaseline": + case "oitSecurityCollaborator": + out.Values[i] = ec._SystemIntake_oitSecurityCollaborator(ctx, field, obj) + case "oitSecurityCollaboratorName": + out.Values[i] = ec._SystemIntake_oitSecurityCollaboratorName(ctx, field, obj) + case "productManager": field := field - innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + 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._SystemIntake_lcidCostBaseline(ctx, field, obj) + res = ec._SystemIntake_productManager(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } return res } @@ -67692,9 +68937,11 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "lcidRetiresAt": - out.Values[i] = ec._SystemIntake_lcidRetiresAt(ctx, field, obj) - case "needsEaSupport": + case "projectAcronym": + out.Values[i] = ec._SystemIntake_projectAcronym(ctx, field, obj) + case "rejectionReason": + out.Values[i] = ec._SystemIntake_rejectionReason(ctx, field, obj) + case "requestName": field := field innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { @@ -67703,7 +68950,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_needsEaSupport(ctx, field, obj) + res = ec._SystemIntake_requestName(ctx, field, obj) return res } @@ -67727,45 +68974,12 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "usingSoftware": - out.Values[i] = ec._SystemIntake_usingSoftware(ctx, field, obj) - case "acquisitionMethods": - 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._SystemIntake_acquisitionMethods(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 + case "requestType": + out.Values[i] = ec._SystemIntake_requestType(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) } - - out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "notes": + case "requester": field := field innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { @@ -67774,7 +68988,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_notes(ctx, field, obj) + res = ec._SystemIntake_requester(ctx, field, obj) if res == graphql.Null { atomic.AddUint32(&fs.Invalids, 1) } @@ -67801,23 +69015,16 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "oitSecurityCollaborator": - out.Values[i] = ec._SystemIntake_oitSecurityCollaborator(ctx, field, obj) - case "oitSecurityCollaboratorName": - out.Values[i] = ec._SystemIntake_oitSecurityCollaboratorName(ctx, field, obj) - case "productManager": + case "requesterName": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_productManager(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } + res = ec._SystemIntake_requesterName(ctx, field, obj) return res } @@ -67841,11 +69048,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "projectAcronym": - out.Values[i] = ec._SystemIntake_projectAcronym(ctx, field, obj) - case "rejectionReason": - out.Values[i] = ec._SystemIntake_rejectionReason(ctx, field, obj) - case "requestName": + case "requesterComponent": field := field innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { @@ -67854,7 +69057,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_requestName(ctx, field, obj) + res = ec._SystemIntake_requesterComponent(ctx, field, obj) return res } @@ -67878,12 +69081,33 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "requestType": - out.Values[i] = ec._SystemIntake_requestType(ctx, field, obj) + case "state": + out.Values[i] = ec._SystemIntake_state(ctx, field, obj) if out.Values[i] == graphql.Null { atomic.AddUint32(&out.Invalids, 1) } - case "requester": + case "step": + out.Values[i] = ec._SystemIntake_step(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "submittedAt": + out.Values[i] = ec._SystemIntake_submittedAt(ctx, field, obj) + case "trbCollaborator": + out.Values[i] = ec._SystemIntake_trbCollaborator(ctx, field, obj) + case "trbCollaboratorName": + out.Values[i] = ec._SystemIntake_trbCollaboratorName(ctx, field, obj) + case "updatedAt": + out.Values[i] = ec._SystemIntake_updatedAt(ctx, field, obj) + case "grtReviewEmailBody": + out.Values[i] = ec._SystemIntake_grtReviewEmailBody(ctx, field, obj) + case "decidedAt": + out.Values[i] = ec._SystemIntake_decidedAt(ctx, field, obj) + case "businessCaseId": + out.Values[i] = ec._SystemIntake_businessCaseId(ctx, field, obj) + case "cedarSystemId": + out.Values[i] = ec._SystemIntake_cedarSystemId(ctx, field, obj) + case "documents": field := field innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { @@ -67892,7 +69116,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_requester(ctx, field, obj) + res = ec._SystemIntake_documents(ctx, field, obj) if res == graphql.Null { atomic.AddUint32(&fs.Invalids, 1) } @@ -67919,16 +69143,23 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "requesterName": + case "hasUiChanges": + out.Values[i] = ec._SystemIntake_hasUiChanges(ctx, field, obj) + case "usesAiTech": + out.Values[i] = ec._SystemIntake_usesAiTech(ctx, field, obj) + case "itGovTaskStatuses": field := field - innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + 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._SystemIntake_requesterName(ctx, field, obj) + res = ec._SystemIntake_itGovTaskStatuses(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } return res } @@ -67952,16 +69183,49 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "requesterComponent": + case "requestFormState": + out.Values[i] = ec._SystemIntake_requestFormState(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "draftBusinessCaseState": + out.Values[i] = ec._SystemIntake_draftBusinessCaseState(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "grtMeetingState": + out.Values[i] = ec._SystemIntake_grtMeetingState(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "finalBusinessCaseState": + out.Values[i] = ec._SystemIntake_finalBusinessCaseState(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "grbMeetingState": + out.Values[i] = ec._SystemIntake_grbMeetingState(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "decisionState": + out.Values[i] = ec._SystemIntake_decisionState(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "statusRequester": field := field - innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + 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._SystemIntake_requesterComponent(ctx, field, obj) + res = ec._SystemIntake_statusRequester(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } return res } @@ -67985,33 +69249,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "state": - out.Values[i] = ec._SystemIntake_state(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } - case "step": - out.Values[i] = ec._SystemIntake_step(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } - case "submittedAt": - out.Values[i] = ec._SystemIntake_submittedAt(ctx, field, obj) - case "trbCollaborator": - out.Values[i] = ec._SystemIntake_trbCollaborator(ctx, field, obj) - case "trbCollaboratorName": - out.Values[i] = ec._SystemIntake_trbCollaboratorName(ctx, field, obj) - case "updatedAt": - out.Values[i] = ec._SystemIntake_updatedAt(ctx, field, obj) - case "grtReviewEmailBody": - out.Values[i] = ec._SystemIntake_grtReviewEmailBody(ctx, field, obj) - case "decidedAt": - out.Values[i] = ec._SystemIntake_decidedAt(ctx, field, obj) - case "businessCaseId": - out.Values[i] = ec._SystemIntake_businessCaseId(ctx, field, obj) - case "cedarSystemId": - out.Values[i] = ec._SystemIntake_cedarSystemId(ctx, field, obj) - case "documents": + case "statusAdmin": field := field innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { @@ -68020,7 +69258,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_documents(ctx, field, obj) + res = ec._SystemIntake_statusAdmin(ctx, field, obj) if res == graphql.Null { atomic.AddUint32(&fs.Invalids, 1) } @@ -68047,23 +69285,16 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "hasUiChanges": - out.Values[i] = ec._SystemIntake_hasUiChanges(ctx, field, obj) - case "usesAiTech": - out.Values[i] = ec._SystemIntake_usesAiTech(ctx, field, obj) - case "itGovTaskStatuses": + case "lcidStatus": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_itGovTaskStatuses(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } + res = ec._SystemIntake_lcidStatus(ctx, field, obj) return res } @@ -68087,49 +69318,20 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "requestFormState": - out.Values[i] = ec._SystemIntake_requestFormState(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } - case "draftBusinessCaseState": - out.Values[i] = ec._SystemIntake_draftBusinessCaseState(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } - case "grtMeetingState": - out.Values[i] = ec._SystemIntake_grtMeetingState(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } - case "finalBusinessCaseState": - out.Values[i] = ec._SystemIntake_finalBusinessCaseState(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } - case "grbMeetingState": - out.Values[i] = ec._SystemIntake_grbMeetingState(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } - case "decisionState": - out.Values[i] = ec._SystemIntake_decisionState(ctx, field, obj) - if out.Values[i] == graphql.Null { - atomic.AddUint32(&out.Invalids, 1) - } - case "statusRequester": + case "trbFollowUpRecommendation": + out.Values[i] = ec._SystemIntake_trbFollowUpRecommendation(ctx, field, obj) + case "contractName": + out.Values[i] = ec._SystemIntake_contractName(ctx, field, obj) + case "relationType": field := field - innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { defer func() { if r := recover(); r != nil { ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_statusRequester(ctx, field, obj) - if res == graphql.Null { - atomic.AddUint32(&fs.Invalids, 1) - } + res = ec._SystemIntake_relationType(ctx, field, obj) return res } @@ -68153,7 +69355,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "statusAdmin": + case "systems": field := field innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { @@ -68162,7 +69364,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_statusAdmin(ctx, field, obj) + res = ec._SystemIntake_systems(ctx, field, obj) if res == graphql.Null { atomic.AddUint32(&fs.Invalids, 1) } @@ -68189,77 +69391,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "lcidStatus": - field := field - - innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - } - }() - res = ec._SystemIntake_lcidStatus(ctx, field, obj) - 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 "trbFollowUpRecommendation": - out.Values[i] = ec._SystemIntake_trbFollowUpRecommendation(ctx, field, obj) - case "contractName": - out.Values[i] = ec._SystemIntake_contractName(ctx, field, obj) - case "relationType": - field := field - - innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { - defer func() { - if r := recover(); r != nil { - ec.Error(ctx, ec.Recover(ctx, r)) - } - }() - res = ec._SystemIntake_relationType(ctx, field, obj) - 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 "systems": + case "contractNumbers": field := field innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { @@ -68268,7 +69400,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_systems(ctx, field, obj) + res = ec._SystemIntake_contractNumbers(ctx, field, obj) if res == graphql.Null { atomic.AddUint32(&fs.Invalids, 1) } @@ -68295,7 +69427,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "contractNumbers": + case "relatedIntakes": field := field innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { @@ -68304,7 +69436,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_contractNumbers(ctx, field, obj) + res = ec._SystemIntake_relatedIntakes(ctx, field, obj) if res == graphql.Null { atomic.AddUint32(&fs.Invalids, 1) } @@ -68331,7 +69463,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "relatedIntakes": + case "relatedTRBRequests": field := field innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { @@ -68340,7 +69472,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_relatedIntakes(ctx, field, obj) + res = ec._SystemIntake_relatedTRBRequests(ctx, field, obj) if res == graphql.Null { atomic.AddUint32(&fs.Invalids, 1) } @@ -68367,7 +69499,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection } out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) - case "relatedTRBRequests": + case "grbDiscussions": field := field innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) { @@ -68376,7 +69508,7 @@ func (ec *executionContext) _SystemIntake(ctx context.Context, sel ast.Selection ec.Error(ctx, ec.Recover(ctx, r)) } }() - res = ec._SystemIntake_relatedTRBRequests(ctx, field, obj) + res = ec._SystemIntake_grbDiscussions(ctx, field, obj) if res == graphql.Null { atomic.AddUint32(&fs.Invalids, 1) } @@ -69199,67 +70331,240 @@ func (ec *executionContext) _SystemIntakeDocument(ctx context.Context, sel ast.S return out } -var systemIntakeDocumentTypeImplementors = []string{"SystemIntakeDocumentType"} +var systemIntakeDocumentTypeImplementors = []string{"SystemIntakeDocumentType"} + +func (ec *executionContext) _SystemIntakeDocumentType(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeDocumentType) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeDocumentTypeImplementors) + + 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("SystemIntakeDocumentType") + case "commonType": + out.Values[i] = ec._SystemIntakeDocumentType_commonType(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "otherTypeDescription": + out.Values[i] = ec._SystemIntakeDocumentType_otherTypeDescription(ctx, field, obj) + 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 systemIntakeFundingSourceImplementors = []string{"SystemIntakeFundingSource"} + +func (ec *executionContext) _SystemIntakeFundingSource(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeFundingSource) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeFundingSourceImplementors) + + 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("SystemIntakeFundingSource") + case "id": + out.Values[i] = ec._SystemIntakeFundingSource_id(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "fundingNumber": + out.Values[i] = ec._SystemIntakeFundingSource_fundingNumber(ctx, field, obj) + case "source": + out.Values[i] = ec._SystemIntakeFundingSource_source(ctx, field, obj) + 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 systemIntakeGRBReviewDiscussionImplementors = []string{"SystemIntakeGRBReviewDiscussion"} + +func (ec *executionContext) _SystemIntakeGRBReviewDiscussion(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeGRBReviewDiscussion) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeGRBReviewDiscussionImplementors) + + 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("SystemIntakeGRBReviewDiscussion") + case "initialPost": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussion_initialPost(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + case "replies": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussion_replies(ctx, field, obj) + if out.Values[i] == graphql.Null { + out.Invalids++ + } + 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 systemIntakeGRBReviewDiscussionPostImplementors = []string{"SystemIntakeGRBReviewDiscussionPost"} -func (ec *executionContext) _SystemIntakeDocumentType(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeDocumentType) graphql.Marshaler { - fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeDocumentTypeImplementors) +func (ec *executionContext) _SystemIntakeGRBReviewDiscussionPost(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeGRBReviewDiscussionPost) graphql.Marshaler { + fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeGRBReviewDiscussionPostImplementors) 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("SystemIntakeDocumentType") - case "commonType": - out.Values[i] = ec._SystemIntakeDocumentType_commonType(ctx, field, obj) + out.Values[i] = graphql.MarshalString("SystemIntakeGRBReviewDiscussionPost") + case "id": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_id(ctx, field, obj) if out.Values[i] == graphql.Null { - out.Invalids++ + atomic.AddUint32(&out.Invalids, 1) } - case "otherTypeDescription": - out.Values[i] = ec._SystemIntakeDocumentType_otherTypeDescription(ctx, field, obj) - default: - panic("unknown field " + strconv.Quote(field.Name)) - } - } - out.Dispatch(ctx) - if out.Invalids > 0 { - return graphql.Null - } + case "content": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_content(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "votingRole": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_votingRole(ctx, field, obj) + case "grbRole": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_grbRole(ctx, field, obj) + case "systemIntakeID": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_systemIntakeID(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "createdByUserAccount": + field := field - atomic.AddInt32(&ec.deferred, int32(len(deferred))) + 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._SystemIntakeGRBReviewDiscussionPost_createdByUserAccount(ctx, field, obj) + if res == graphql.Null { + atomic.AddUint32(&fs.Invalids, 1) + } + return res + } - for label, dfs := range deferred { - ec.processDeferredGroup(graphql.DeferredGroup{ - Label: label, - Path: graphql.GetPath(ctx), - FieldSet: dfs, - Context: ctx, - }) - } + 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) + }) - return out -} + // don't run the out.Concurrently() call below + out.Values[i] = graphql.Null + continue + } -var systemIntakeFundingSourceImplementors = []string{"SystemIntakeFundingSource"} + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "createdAt": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_createdAt(ctx, field, obj) + if out.Values[i] == graphql.Null { + atomic.AddUint32(&out.Invalids, 1) + } + case "modifiedByUserAccount": + field := field -func (ec *executionContext) _SystemIntakeFundingSource(ctx context.Context, sel ast.SelectionSet, obj *models.SystemIntakeFundingSource) graphql.Marshaler { - fields := graphql.CollectFields(ec.OperationContext, sel, systemIntakeFundingSourceImplementors) + innerFunc := func(ctx context.Context, _ *graphql.FieldSet) (res graphql.Marshaler) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + } + }() + res = ec._SystemIntakeGRBReviewDiscussionPost_modifiedByUserAccount(ctx, field, obj) + return res + } - 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("SystemIntakeFundingSource") - case "id": - out.Values[i] = ec._SystemIntakeFundingSource_id(ctx, field, obj) - if out.Values[i] == graphql.Null { - out.Invalids++ + 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 } - case "fundingNumber": - out.Values[i] = ec._SystemIntakeFundingSource_fundingNumber(ctx, field, obj) - case "source": - out.Values[i] = ec._SystemIntakeFundingSource_source(ctx, field, obj) + + out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) }) + case "modifiedAt": + out.Values[i] = ec._SystemIntakeGRBReviewDiscussionPost_modifiedAt(ctx, field, obj) default: panic("unknown field " + strconv.Quote(field.Name)) } @@ -74901,6 +76206,114 @@ func (ec *executionContext) unmarshalNSystemIntakeFundingSourceInput2ᚖgithub return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) marshalNSystemIntakeGRBReviewDiscussion2ᚕᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.SystemIntakeGRBReviewDiscussion) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNSystemIntakeGRBReviewDiscussion2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussion(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNSystemIntakeGRBReviewDiscussion2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussion(ctx context.Context, sel ast.SelectionSet, v *models.SystemIntakeGRBReviewDiscussion) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._SystemIntakeGRBReviewDiscussion(ctx, sel, v) +} + +func (ec *executionContext) marshalNSystemIntakeGRBReviewDiscussionPost2ᚕᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPostᚄ(ctx context.Context, sel ast.SelectionSet, v []*models.SystemIntakeGRBReviewDiscussionPost) graphql.Marshaler { + ret := make(graphql.Array, len(v)) + var wg sync.WaitGroup + isLen1 := len(v) == 1 + if !isLen1 { + wg.Add(len(v)) + } + for i := range v { + i := i + fc := &graphql.FieldContext{ + Index: &i, + Result: &v[i], + } + ctx := graphql.WithFieldContext(ctx, fc) + f := func(i int) { + defer func() { + if r := recover(); r != nil { + ec.Error(ctx, ec.Recover(ctx, r)) + ret = nil + } + }() + if !isLen1 { + defer wg.Done() + } + ret[i] = ec.marshalNSystemIntakeGRBReviewDiscussionPost2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPost(ctx, sel, v[i]) + } + if isLen1 { + f(i) + } else { + go f(i) + } + + } + wg.Wait() + + for _, e := range ret { + if e == graphql.Null { + return graphql.Null + } + } + + return ret +} + +func (ec *executionContext) marshalNSystemIntakeGRBReviewDiscussionPost2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPost(ctx context.Context, sel ast.SelectionSet, v *models.SystemIntakeGRBReviewDiscussionPost) graphql.Marshaler { + if v == nil { + if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) { + ec.Errorf(ctx, "the requested element is null which the schema does not allow") + } + return graphql.Null + } + return ec._SystemIntakeGRBReviewDiscussionPost(ctx, sel, v) +} + func (ec *executionContext) marshalNSystemIntakeGRBReviewer2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewer(ctx context.Context, sel ast.SelectionSet, v models.SystemIntakeGRBReviewer) graphql.Marshaler { return ec._SystemIntakeGRBReviewer(ctx, sel, &v) } @@ -76205,6 +77618,16 @@ func (ec *executionContext) marshalNTRBTaskStatuses2ᚖgithubᚗcomᚋcmsᚑente return ec._TRBTaskStatuses(ctx, sel, v) } +func (ec *executionContext) unmarshalNTaggedHTML2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐTaggedHTML(ctx context.Context, v interface{}) (models.TaggedHTML, error) { + var res models.TaggedHTML + err := res.UnmarshalGQLContext(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalNTaggedHTML2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐTaggedHTML(ctx context.Context, sel ast.SelectionSet, v models.TaggedHTML) graphql.Marshaler { + return graphql.WrapContextMarshaler(ctx, v) +} + func (ec *executionContext) unmarshalNTime2timeᚐTime(ctx context.Context, v interface{}) (time.Time, error) { res, err := graphql.UnmarshalTime(v) return res, graphql.ErrorOnPath(ctx, err) @@ -76719,6 +78142,16 @@ func (ec *executionContext) marshalN__TypeKind2string(ctx context.Context, sel a return res } +func (ec *executionContext) unmarshalNcreateSystemIntakeGRBDiscussionPostInput2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐCreateSystemIntakeGRBDiscussionPostInput(ctx context.Context, v interface{}) (models.CreateSystemIntakeGRBDiscussionPostInput, error) { + res, err := ec.unmarshalInputcreateSystemIntakeGRBDiscussionPostInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) unmarshalNcreateSystemIntakeGRBDiscussionReplyInput2githubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐCreateSystemIntakeGRBDiscussionReplyInput(ctx context.Context, v interface{}) (models.CreateSystemIntakeGRBDiscussionReplyInput, error) { + res, err := ec.unmarshalInputcreateSystemIntakeGRBDiscussionReplyInput(ctx, v) + return res, graphql.ErrorOnPath(ctx, err) +} + func (ec *executionContext) unmarshalOBoolean2bool(ctx context.Context, v interface{}) (bool, error) { res, err := graphql.UnmarshalBoolean(v) return res, graphql.ErrorOnPath(ctx, err) @@ -77397,6 +78830,45 @@ func (ec *executionContext) unmarshalOSystemIntakeFundingSourcesInput2ᚖgithub return &res, graphql.ErrorOnPath(ctx, err) } +func (ec *executionContext) marshalOSystemIntakeGRBReviewDiscussionPost2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewDiscussionPost(ctx context.Context, sel ast.SelectionSet, v *models.SystemIntakeGRBReviewDiscussionPost) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return ec._SystemIntakeGRBReviewDiscussionPost(ctx, sel, v) +} + +func (ec *executionContext) unmarshalOSystemIntakeGRBReviewerRole2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewerRole(ctx context.Context, v interface{}) (*models.SystemIntakeGRBReviewerRole, error) { + if v == nil { + return nil, nil + } + var res = new(models.SystemIntakeGRBReviewerRole) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOSystemIntakeGRBReviewerRole2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewerRole(ctx context.Context, sel ast.SelectionSet, v *models.SystemIntakeGRBReviewerRole) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + +func (ec *executionContext) unmarshalOSystemIntakeGRBReviewerVotingRole2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewerVotingRole(ctx context.Context, v interface{}) (*models.SystemIntakeGRBReviewerVotingRole, error) { + if v == nil { + return nil, nil + } + var res = new(models.SystemIntakeGRBReviewerVotingRole) + err := res.UnmarshalGQL(v) + return res, graphql.ErrorOnPath(ctx, err) +} + +func (ec *executionContext) marshalOSystemIntakeGRBReviewerVotingRole2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeGRBReviewerVotingRole(ctx context.Context, sel ast.SelectionSet, v *models.SystemIntakeGRBReviewerVotingRole) graphql.Marshaler { + if v == nil { + return graphql.Null + } + return v +} + func (ec *executionContext) marshalOSystemIntakeLCIDExpirationChange2ᚖgithubᚗcomᚋcmsᚑenterpriseᚋeasiᚑappᚋpkgᚋmodelsᚐSystemIntakeLCIDExpirationChange(ctx context.Context, sel ast.SelectionSet, v *models.SystemIntakeLCIDExpirationChange) graphql.Marshaler { if v == nil { return graphql.Null diff --git a/pkg/graph/resolvers/resolver_test.go b/pkg/graph/resolvers/resolver_test.go index cb0dbcc983..3f621151fa 100644 --- a/pkg/graph/resolvers/resolver_test.go +++ b/pkg/graph/resolvers/resolver_test.go @@ -48,7 +48,7 @@ func (s *ResolverSuite) SetupTest() { assert.NoError(s.T(), err) // Get the user account from the DB fresh for each test - princ := s.getTestPrincipal(s.testConfigs.Context, s.testConfigs.Store, s.testConfigs.UserInfo.Username) + princ := s.getTestPrincipal(s.testConfigs.Context, s.testConfigs.Store, s.testConfigs.UserInfo.Username, true) s.testConfigs.Principal = princ // get new dataloaders to clear any existing cached data @@ -62,6 +62,11 @@ func (s *ResolverSuite) SetupTest() { s.testConfigs.Sender.Clear() } +func (s *ResolverSuite) getTestContextWithPrincipal(euaID string, isAdmin bool) (context.Context, *authentication.EUAPrincipal) { + princ := s.getTestPrincipal(s.testConfigs.Context, s.testConfigs.Store, euaID, isAdmin) + return appcontext.WithPrincipal(s.testConfigs.Context, princ), princ +} + // TestResolverSuite runs the resolver test suite func TestResolverSuite(t *testing.T) { rs := new(ResolverSuite) @@ -156,10 +161,9 @@ func (tc *TestConfigs) GetDefaults() { tc.EmailClient = emailClient } -func NewEmailClient() (*email.Client, *mockSender) { - sender := &mockSender{} +func getTestEmailConfig() email.Config { config := testhelpers.NewConfig() - emailConfig := email.Config{ + return email.Config{ GRTEmail: models.NewEmailAddress(config.GetString(appconfig.GRTEmailKey)), ITInvestmentEmail: models.NewEmailAddress(config.GetString(appconfig.ITInvestmentEmailKey)), TRBEmail: models.NewEmailAddress(config.GetString(appconfig.TRBEmailKey)), @@ -168,21 +172,26 @@ func NewEmailClient() (*email.Client, *mockSender) { URLScheme: config.GetString(appconfig.ClientProtocolKey), TemplateDirectory: config.GetString(appconfig.EmailTemplateDirectoryKey), } +} +func NewEmailClient() (*email.Client, *mockSender) { + sender := &mockSender{} + emailConfig := getTestEmailConfig() emailClient, _ := email.NewClient(emailConfig, sender) return &emailClient, sender } // getTestPrincipal gets a user principal from database -func (s *ResolverSuite) getTestPrincipal(ctx context.Context, store *storage.Store, userName string) *authentication.EUAPrincipal { +func (s *ResolverSuite) getTestPrincipal(ctx context.Context, store *storage.Store, userName string, isAdmin bool) *authentication.EUAPrincipal { userAccount, _ := userhelpers.GetOrCreateUserAccount(ctx, store, store, userName, true, userhelpers.GetUserInfoAccountInfoWrapperFunc(s.testConfigs.UserSearchClient.FetchUserInfo)) princ := &authentication.EUAPrincipal{ - EUAID: userName, - JobCodeEASi: true, - JobCodeGRT: true, - UserAccount: userAccount, + EUAID: userName, + JobCodeEASi: true, + JobCodeGRT: isAdmin, + JobCodeTRBAdmin: isAdmin, + UserAccount: userAccount, } return princ @@ -263,7 +272,7 @@ func (s *ResolverSuite) getOrCreateUserAcct(euaUserID string) *authentication.Us } // utility method to get userAcct in resolver tests -func (s *ResolverSuite) getOrCreateUserAccts(euaUserIDs []string) []*authentication.UserAccount { +func (s *ResolverSuite) getOrCreateUserAccts(euaUserIDs ...string) []*authentication.UserAccount { ctx := s.testConfigs.Context store := s.testConfigs.Store okta := local.NewOktaAPIClient() diff --git a/pkg/graph/resolvers/system_intake_document.go b/pkg/graph/resolvers/system_intake_document.go index 5307b4666e..c58f7e2168 100644 --- a/pkg/graph/resolvers/system_intake_document.go +++ b/pkg/graph/resolvers/system_intake_document.go @@ -138,7 +138,7 @@ func DeleteSystemIntakeDocument(ctx context.Context, store *storage.Store, id uu // CanViewDocument determines if a user can view a document func CanViewDocument(ctx context.Context, grbUsers []*models.SystemIntakeGRBReviewer, document *models.SystemIntakeDocument) bool { // admins can view - if services.HasRole(ctx, models.RoleEasiGovteam) { + if services.AuthorizeRequireGRTJobCode(ctx) { return true } diff --git a/pkg/graph/resolvers/system_intake_grb_discussions.go b/pkg/graph/resolvers/system_intake_grb_discussions.go new file mode 100644 index 0000000000..cd75d32026 --- /dev/null +++ b/pkg/graph/resolvers/system_intake_grb_discussions.go @@ -0,0 +1,376 @@ +package resolvers + +import ( + "context" + "errors" + "fmt" + "html/template" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + "github.com/samber/lo" + "go.uber.org/zap" + + "github.com/cms-enterprise/easi-app/pkg/appcontext" + "github.com/cms-enterprise/easi-app/pkg/email" + "github.com/cms-enterprise/easi-app/pkg/models" + "github.com/cms-enterprise/easi-app/pkg/services" + "github.com/cms-enterprise/easi-app/pkg/sqlutils" + "github.com/cms-enterprise/easi-app/pkg/storage" +) + +// CreateSystemIntakeGRBDiscussionPost creates an initial GRB discussion post +func CreateSystemIntakeGRBDiscussionPost( + ctx context.Context, + store *storage.Store, + emailClient *email.Client, + input models.CreateSystemIntakeGRBDiscussionPostInput, +) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + return sqlutils.WithTransactionRet(ctx, store, func(tx *sqlx.Tx) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + principal := appcontext.Principal(ctx) + + intakeID := input.SystemIntakeID + + // fetch system intake for email logic + systemIntake, err := store.FetchSystemIntakeByIDNP(ctx, tx, intakeID) + if err != nil { + return nil, err + } + + principalAsGRBReviewer, err := GetPrincipalAsGRBReviewerBySystemIntakeID(ctx, intakeID) + if err != nil { + return nil, err + } + + isAdmin := services.AuthorizeRequireGRTJobCode(ctx) + if principalAsGRBReviewer == nil && !isAdmin { + return nil, errors.New("user not authorized to create discussion post") + } + + post := models.NewSystemIntakeGRBReviewDiscussionPost(principal.Account().ID) + post.Content = input.Content.RawContent + post.SystemIntakeID = intakeID + if principalAsGRBReviewer != nil { + post.VotingRole = &principalAsGRBReviewer.GRBVotingRole + post.GRBRole = &principalAsGRBReviewer.GRBReviewerRole + } + + // save in DB + result, err := store.CreateSystemIntakeGRBDiscussionPost(ctx, tx, post) + if err != nil { + return nil, err + } + + if emailClient == nil { + return result, nil + } + + authorRole, err := getAuthorRoleFromPost(post) + if err != nil { + return nil, err + } + + err = sendDiscussionEmailsForTags( + ctx, + store, + emailClient, + tx, + intakeID, + systemIntake.ProjectName.String, + post.ID, + authorRole, + input.Content.UniqueTags(), + input.Content.ToTemplate(), + ) + if err != nil { + return nil, err + } + + return result, nil + }) +} + +// CreateSystemIntakeGRBDiscussionReply creates a reply to a GRB Discussion post +func CreateSystemIntakeGRBDiscussionReply( + ctx context.Context, + store *storage.Store, + emailClient *email.Client, + input models.CreateSystemIntakeGRBDiscussionReplyInput, +) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + return sqlutils.WithTransactionRet(ctx, store, func(tx *sqlx.Tx) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + initialPost, err := store.GetSystemIntakeGRBDiscussionPostByID(ctx, tx, input.InitialPostID) + if err != nil { + return nil, err + } + + intakeID := initialPost.SystemIntakeID + if initialPost.ReplyToID != nil { + return nil, errors.New("only top level posts can be replied to") + } + + principalGRBReviewer, err := GetPrincipalAsGRBReviewerBySystemIntakeID(ctx, intakeID) + if err != nil { + return nil, err + } + + isAdmin := services.AuthorizeRequireGRTJobCode(ctx) + if principalGRBReviewer == nil && !isAdmin { + return nil, errors.New("user not authorized to create discussion post") + } + + // get user who made this reply post + replyPoster := appcontext.Principal(ctx).Account() + post := models.NewSystemIntakeGRBReviewDiscussionPost(replyPoster.ID) + post.Content = input.Content.RawContent + post.SystemIntakeID = intakeID + post.ReplyToID = &initialPost.ID + if principalGRBReviewer != nil { + post.VotingRole = &principalGRBReviewer.GRBVotingRole + post.GRBRole = &principalGRBReviewer.GRBReviewerRole + } + + result, err := store.CreateSystemIntakeGRBDiscussionPost(ctx, tx, post) + if err != nil { + return nil, err + } + + systemIntake, err := store.FetchSystemIntakeByIDNP(ctx, tx, intakeID) + if err != nil { + return nil, err + } + + if systemIntake == nil { + return nil, errors.New("problem finding system intake when handling GRB reply") + } + + // the initial poster will receive a notification + // in the event the initial poster is also tagged in a reply, we do not send both emails + // we only send the "someone replied" email + initialPoster, err := store.UserAccountGetByID(ctx, tx, initialPost.CreatedBy) + if err != nil { + return nil, err + } + + if initialPoster == nil { + return nil, errors.New("problem finding initial poster when handling GRB reply") + } + + // if no email client, do not proceed + if emailClient == nil { + return result, nil + } + + // so first, we can send the reply email + authorRole, err := getAuthorRoleFromPost(post) + if err != nil { + return nil, err + } + + // don't send email to author if reply is from author + if initialPoster.ID != replyPoster.ID { + if err := emailClient.SystemIntake.SendGRBReviewDiscussionReplyEmail(ctx, email.SendGRBReviewDiscussionReplyEmailInput{ + SystemIntakeID: intakeID, + UserName: replyPoster.CommonName, + RequestName: systemIntake.ProjectName.String, + DiscussionID: initialPost.ID, + Role: authorRole, + DiscussionContent: input.Content.ToTemplate(), + Recipient: models.EmailAddress(initialPoster.Email), + }); err != nil { + return nil, err + } + } + + uniqueTags := input.Content.UniqueTags() + // strip initial post author from tags in case author was tagged + uniqueTagsWithoutInitialPoster := lo.Filter(uniqueTags, func(t *models.Tag, _ int) bool { + return t.TaggedContentID != initialPoster.ID + }) + + // then handle emails for tags in the post + err = sendDiscussionEmailsForTags( + ctx, + store, + emailClient, + tx, + intakeID, + systemIntake.ProjectName.String, + initialPost.ID, + authorRole, + uniqueTagsWithoutInitialPoster, + input.Content.ToTemplate(), + ) + if err != nil { + return nil, err + } + + return result, nil + }) +} + +// handles sending emails for various tags in a discussion post +func sendDiscussionEmailsForTags( + ctx context.Context, + store *storage.Store, + emailClient *email.Client, + tx *sqlx.Tx, + intakeID uuid.UUID, + intakeRequestName string, + discussionID uuid.UUID, + postAuthorRole string, + uniqueTags []*models.Tag, + content template.HTML, +) error { + // if no tags, we can return here + if len(uniqueTags) < 1 { + return nil + } + + logger := appcontext.ZLogger(ctx) + principal := appcontext.Principal(ctx) + + grbReviewers, err := store.SystemIntakeGRBReviewersBySystemIntakeIDsNP(ctx, tx, []uuid.UUID{intakeID}) + if err != nil { + return err + } + + grbReviewerCache := map[uuid.UUID]*models.SystemIntakeGRBReviewer{} + // map for ease + for _, grbReviewer := range grbReviewers { + // not sure if possible, but just in case + if grbReviewer == nil { + continue + } + // skip including author/current user in Reviewers to be tagged + // individual tags are also built from this cache below, so this will exclude the author from the + // GRB group tag email as well as an individual tag email + // GRT admin tags go to ITGov inbox, so an admin author will still receive an admin tag email + if grbReviewer.UserID == principal.Account().ID { + continue + } + + grbReviewerCache[grbReviewer.UserID] = grbReviewer + } + + // check if the grb group is being emailed, in which case we should make sure we do not send any individual emails out + var grbGroupFound bool + + groupTagTypes := []models.TagType{} + individualTagAcctIDs := []uuid.UUID{} + + // split individual and group tags + for _, tag := range uniqueTags { + if tag == nil { + continue + } + if tag.TagType == models.TagTypeUserAccount { + if _, ok := grbReviewerCache[tag.TaggedContentID]; !ok { + // this means someone was tagged who should not have been + logger.Info("tagged user is not a grb reviewer for this intake", zap.String("systemIntakeID", intakeID.String())) + continue + } + individualTagAcctIDs = append(individualTagAcctIDs, tag.TaggedContentID) + } else { + if tag.TagType == models.TagTypeGroupGrbReviewers { + grbGroupFound = true + } + groupTagTypes = append(groupTagTypes, tag.TagType) + } + } + + // handle group tags + if len(groupTagTypes) > 0 { + // send email for each tag group + for _, groupTagType := range groupTagTypes { + recipients := models.EmailNotificationRecipients{} + var groupName string + + switch groupTagType { + + case models.TagTypeGroupItGov: + recipients.ShouldNotifyITGovernance = true + groupName = "Governance Admin Team" + + case models.TagTypeGroupGrbReviewers: + groupName = "GRB" + reviewerIDs := lo.Keys(grbReviewerCache) + grbAccts, err := store.UserAccountsByIDsNP(ctx, tx, reviewerIDs) + if err != nil { + logger.Error("problem getting recipients by id when sending out tag email notifications", zap.Error(err)) + return err + } + for _, acct := range grbAccts { + recipients.RegularRecipientEmails = append(recipients.RegularRecipientEmails, models.EmailAddress(acct.Email)) + } + + // should never happen, but skip these cases + case models.TagTypeUserAccount: + continue + default: + continue + } + + if err := emailClient.SystemIntake.SendGRBReviewDiscussionGroupTaggedEmail(ctx, email.SendGRBReviewDiscussionGroupTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: principal.Account().CommonName, + GroupName: groupName, + RequestName: intakeRequestName, + Role: postAuthorRole, + DiscussionID: discussionID, + DiscussionContent: content, + Recipients: recipients, + }); err != nil { + return err + } + } + } + + // handle and get email addresses for individual tags + if !grbGroupFound && len(individualTagAcctIDs) > 0 { + // for individual tags, we need to build an email based on the passed in UUID + recipientAccts, err := store.UserAccountsByIDsNP(ctx, tx, individualTagAcctIDs) + if err != nil { + logger.Error("problem getting recipients by id when sending out tag email notifications", zap.Error(err)) + return err + } + recipients := []models.EmailAddress{} + for _, recipientAcct := range recipientAccts { + recipients = append(recipients, models.EmailAddress(recipientAcct.Email)) + } + + if err := emailClient.SystemIntake.SendGRBReviewDiscussionIndividualTaggedEmail(ctx, email.SendGRBReviewDiscussionIndividualTaggedEmailInput{ + SystemIntakeID: intakeID, + UserName: principal.Account().CommonName, + RequestName: intakeRequestName, + Role: postAuthorRole, + DiscussionID: discussionID, + DiscussionContent: content, + Recipients: recipients, + }); err != nil { + return err + } + } + return nil +} + +func getAuthorRoleFromPost(post *models.SystemIntakeGRBReviewDiscussionPost) (string, error) { + if post.VotingRole == nil || post.GRBRole == nil { + return "Governance Admin Team", nil + } + + if len(*post.VotingRole) < 1 || len(*post.GRBRole) < 1 { + return "Governance Admin Team", nil + } + + votingRoleStr, err := post.VotingRole.Humanize() + if err != nil { + return "", err + } + + grbRoleStr, err := post.GRBRole.Humanize() + if err != nil { + return "", err + } + + return fmt.Sprintf("%[1]s member, %[2]s", votingRoleStr, grbRoleStr), nil +} diff --git a/pkg/graph/resolvers/system_intake_grb_discussions_test.go b/pkg/graph/resolvers/system_intake_grb_discussions_test.go new file mode 100644 index 0000000000..db157a889e --- /dev/null +++ b/pkg/graph/resolvers/system_intake_grb_discussions_test.go @@ -0,0 +1,620 @@ +package resolvers + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/samber/lo" + + "github.com/cms-enterprise/easi-app/pkg/appcontext" + "github.com/cms-enterprise/easi-app/pkg/email" + "github.com/cms-enterprise/easi-app/pkg/models" + "github.com/cms-enterprise/easi-app/pkg/userhelpers" +) + +func (s *ResolverSuite) TestSystemIntakeGRBDiscussions() { + store := s.testConfigs.Store + + s.Run("create and retrieve initial discussion", func() { + emailClient, _ := NewEmailClient() + + intake := s.createNewIntake() + ctx, _ := s.getTestContextWithPrincipal("ABCD", true) + post := s.createGRBDiscussion( + ctx, + emailClient, + intake.ID, + "
banana
", + ) + + // test the resolver for retrieving discussions + discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) + s.NotNil(discussions) + s.NoError(err) + s.Len(discussions, 1) + s.Equal(discussions[0].InitialPost.ID, post.ID) + s.Equal(discussions[0].InitialPost.CreatedBy, post.CreatedBy) + s.Equal(discussions[0].InitialPost.Content, post.Content) + s.Len(discussions[0].Replies, 0) + }) + + s.Run("create GRB discussion and add to intake as admin", func() { + emailClient, _ := NewEmailClient() + + intake := s.createNewIntake() + ctx, _ := s.getTestContextWithPrincipal("ABCD", true) + s.createGRBDiscussion( + ctx, + emailClient, + intake.ID, + "banana
", + ) + + }) + + s.Run("create GRB discussion and add to intake as reviewer", func() { + emailClient, _ := NewEmailClient() + + intake, _ := s.createIntakeAndAddReviewersByEUAs("ABCD") + + ctx, _ := s.getTestContextWithPrincipal("ABCD", false) + s.createGRBDiscussion( + ctx, + emailClient, + intake.ID, + "banana
", + ) + }) + + s.Run("cannot create GRB discussion if not reviewer or admin", func() { + emailClient, _ := NewEmailClient() + + intake := s.createNewIntake() + ctx, _ := s.getTestContextWithPrincipal("ABCD", false) + post, err := CreateSystemIntakeGRBDiscussionPost( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionPostInput{ + SystemIntakeID: intake.ID, + Content: models.TaggedHTML{ + RawContent: "banana
", + }, + }, + ) + s.Nil(post) + s.Error(err) + }) + + s.Run("tagged reviewers should receive an email", func() { + emailClient, sender := NewEmailClient() + + intake, _ := s.createIntakeAndAddReviewersByEUAs("ABCD", "BTMN", "USR2") + + ctx, _ := s.getTestContextWithPrincipal("ABCD", false) + content := "banana
" + + usersToEmail := s.getOrCreateUserAccts("BTMN", "USR2") + for _, user := range usersToEmail { + content = addTags( + content, + models.Tag{ + TagType: models.TagTypeUserAccount, + TaggedContentID: user.ID, + }, + ) + } + + s.createGRBDiscussion( + ctx, + emailClient, + intake.ID, + content, + ) + + s.Len(sender.sentEmails, 1) + s.Equal(sender.subject, fmt.Sprintf("You were tagged in a GRB Review discussion for %s", intake.ProjectName.String)) + // should exclude ABCD as author of reply + s.Len(sender.bccAddresses, 2) + for _, user := range usersToEmail { + s.Contains(sender.bccAddresses, models.EmailAddress(user.Email)) + } + }) + + s.Run("tagging groups should send emails", func() { + emailClient, sender := NewEmailClient() + + intake, _ := s.createIntakeAndAddReviewersByEUAs("ABCD", "BTMN", "USR2") + + ctx, _ := s.getTestContextWithPrincipal("ABCD", false) + content := "banana
" + + // author should be excluded from emails + reviewerAccts := s.getOrCreateUserAccts("BTMN", "USR2") + + content = addTags( + content, + models.Tag{ + TagType: models.TagTypeGroupGrbReviewers, + }, + models.Tag{ + TagType: models.TagTypeGroupItGov, + }, + ) + + s.createGRBDiscussion( + ctx, + emailClient, + intake.ID, + content, + ) + + s.Len(sender.sentEmails, 2) + grtEmail, found := lo.Find(sender.sentEmails, func(email email.Email) bool { + return email.Subject == fmt.Sprintf("The Governance Admin Team was tagged in a GRB Review discussion for %s", intake.ProjectName.String) + }) + s.True(found) + s.Len(grtEmail.CcAddresses, 1) + s.Equal(grtEmail.CcAddresses[0], getTestEmailConfig().GRTEmail) + + grbEmail, found := lo.Find(sender.sentEmails, func(email email.Email) bool { + return email.Subject == fmt.Sprintf("The GRB was tagged in a GRB Review discussion for %s", intake.ProjectName.String) + }) + + s.True(found) + s.NotNil(grbEmail) + // should exclude ABCD as author of reply + s.Len(grbEmail.BccAddresses, len(reviewerAccts)) + for _, user := range reviewerAccts { + s.Contains(grbEmail.BccAddresses, models.EmailAddress(user.Email)) + } + }) +} + +func (s *ResolverSuite) TestSystemIntakeGRBDiscussionReplies() { + store := s.testConfigs.Store + + s.Run("reply to GRB discussion as admin", func() { + emailClient, _ := NewEmailClient() + + intake := s.createNewIntake() + + ctx, princ := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "this is a discussion
") + + replyPost := s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + "banana
", + ) + + // fetch discussion using resolver + discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) + s.NoError(err) + s.NotNil(discussions) + s.Len(discussions, 1) + discussion := discussions[0] + s.Equal(discussion.InitialPost.ID, discussionPost.ID) + + // test discussion reply from resolver + s.Len(discussions[0].Replies, 1) + reply := discussions[0].Replies[0] + s.Equal(reply.ID, replyPost.ID) + s.Equal(princ.UserAccount.ID, reply.CreatedBy) + s.NotNil(reply.ReplyToID) + s.Equal(discussion.InitialPost.ID, *reply.ReplyToID) + s.Equal(reply.Content, models.HTML("banana
")) + }) + + s.Run("reply to GRB discussion as reviewer", func() { + emailClient, _ := NewEmailClient() + + intake, _ := s.createIntakeAndAddReviewersByEUAs("BTMN") + + ctx, discAuthor := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "this is a discussion
") + + ctx, replyAuthor := s.getTestContextWithPrincipal("BTMN", false) + replyPost := s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + "banana
", + ) + + // fetch discussion using resolver + discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) + s.NoError(err) + s.NotNil(discussions) + s.Len(discussions, 1) + discussion := discussions[0] + s.Equal(discussion.InitialPost.ID, discussionPost.ID) + s.Equal(discussion.InitialPost.CreatedBy, discAuthor.UserAccount.ID) + + // test discussion reply from resolver + s.Len(discussions[0].Replies, 1) + reply := discussions[0].Replies[0] + s.Equal(reply.ID, replyPost.ID) + s.Equal(replyAuthor.UserAccount.ID, reply.CreatedBy) + s.NotNil(reply.ReplyToID) + s.Equal(discussion.InitialPost.ID, *reply.ReplyToID) + s.Equal(reply.Content, models.HTML("banana
")) + }) + + s.Run("should not allow reply to GRB discussion when not reviewer nor admin", func() { + emailClient, _ := NewEmailClient() + + intake := s.createNewIntake() + + ctx, discAuthor := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "this is a discussion
") + + ctx, _ = s.getTestContextWithPrincipal("USR2", false) + replyPost, err := CreateSystemIntakeGRBDiscussionReply( + ctx, + store, + emailClient, + models.CreateSystemIntakeGRBDiscussionReplyInput{ + InitialPostID: discussionPost.ID, + Content: models.TaggedHTML{ + RawContent: "banana
", + }, + }, + ) + s.Nil(replyPost) + s.Error(err) + + // fetch discussion using resolver + discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) + s.NoError(err) + s.NotNil(discussions) + s.Len(discussions, 1) + discussion := discussions[0] + s.Equal(discussion.InitialPost.ID, discussionPost.ID) + s.Equal(discussion.InitialPost.CreatedBy, discAuthor.UserAccount.ID) + + // discussion should have no replies + s.Len(discussions[0].Replies, 0) + }) + + s.Run("Should allow for multiple replies", func() { + emailClient, _ := NewEmailClient() + + intake, _ := s.createIntakeAndAddReviewersByEUAs("BTMN", "ABCD") + + ctx, discAuthor := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "this is a discussion
") + + ctx, reply1Author := s.getTestContextWithPrincipal("BTMN", false) + reply1Post := s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + "banana
", + ) + + ctx, reply2Author := s.getTestContextWithPrincipal("ABCD", false) + reply2Post := s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + "tangerine
", + ) + + // fetch discussion using resolver + discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) + s.NoError(err) + s.NotNil(discussions) + s.Len(discussions, 1) + discussion := discussions[0] + s.Equal(discussion.InitialPost.ID, discussionPost.ID) + s.Equal(discussion.InitialPost.CreatedBy, discAuthor.UserAccount.ID) + + // test replies + s.Len(discussions[0].Replies, 2) + + reply1 := discussions[0].Replies[0] + s.Equal(reply1Post.ID, reply1.ID) + s.Equal(reply1Author.UserAccount.ID, reply1.CreatedBy) + s.NotNil(reply1.ReplyToID) + s.Equal(discussion.InitialPost.ID, *reply1.ReplyToID) + s.Equal(reply1.Content, models.HTML("banana
")) + + reply2 := discussions[0].Replies[1] + s.Equal(reply2Post.ID, reply2.ID) + s.Equal(reply2Author.UserAccount.ID, reply2.CreatedBy) + s.NotNil(reply2.ReplyToID) + s.Equal(discussion.InitialPost.ID, *reply2.ReplyToID) + s.Equal(reply2.Content, models.HTML("tangerine
")) + }) + + s.Run("Should allow for replies on different discussions", func() { + emailClient, _ := NewEmailClient() + + intake, _ := s.createIntakeAndAddReviewersByEUAs("BTMN", "ABCD") + + // create two discussions + ctx, disc1Author := s.getTestContextWithPrincipal("USR1", true) + discussion1Post := s.createGRBDiscussion(ctx, emailClient, intake.ID, "this is a discussion
") + + ctx, disc2Author := s.getTestContextWithPrincipal("USR2", true) + discussion2Post := s.createGRBDiscussion(ctx, emailClient, intake.ID, "this is a second discussion
") + + // reply to the first discussion + ctx, reply1Author := s.getTestContextWithPrincipal("BTMN", false) + reply1Post := s.createGRBDiscussionReply( + ctx, + emailClient, + discussion1Post, + "banana
", + ) + + // reply to the second discussion + ctx, reply2Author := s.getTestContextWithPrincipal("ABCD", false) + reply2Post := s.createGRBDiscussionReply( + ctx, + emailClient, + discussion2Post, + "tangerine
", + ) + + // fetch discussions using resolver + discussions, err := SystemIntakeGRBDiscussions(ctx, store, intake.ID) + s.NoError(err) + s.NotNil(discussions) + s.Len(discussions, 2) + discussion1 := discussions[0] + discussion2 := discussions[1] + s.Equal(discussion1.InitialPost.ID, discussion1Post.ID) + s.Equal(discussion2.InitialPost.ID, discussion2Post.ID) + s.Equal(discussion1.InitialPost.CreatedBy, disc1Author.UserAccount.ID) + s.Equal(discussion2.InitialPost.CreatedBy, disc2Author.UserAccount.ID) + + // test each discussion's reply + s.Len(discussions[0].Replies, 1) + s.Len(discussions[1].Replies, 1) + + reply1 := discussion1.Replies[0] + s.Equal(reply1.ID, reply1Post.ID) + s.Equal(reply1Author.UserAccount.ID, reply1.CreatedBy) + s.NotNil(reply1.ReplyToID) + s.Equal(discussion1.InitialPost.ID, *reply1.ReplyToID) + s.Equal(reply1.Content, models.HTML("banana
")) + + reply2 := discussion2.Replies[0] + s.Equal(reply2.ID, reply2Post.ID) + s.Equal(reply2Author.UserAccount.ID, reply2.CreatedBy) + s.NotNil(reply2.ReplyToID) + s.Equal(discussion2.InitialPost.ID, *reply2.ReplyToID) + s.Equal(reply2.Content, models.HTML("tangerine
")) + }) + + s.Run("replies should email discussion author", func() { + emailClient, sender := NewEmailClient() + + intake := s.createNewIntake() + + ctx, discussionAuthor := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "this is a discussion
") + + ctx, _ = s.getTestContextWithPrincipal("USR2", true) + s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + "banana
", + ) + + s.True(sender.emailWasSent) + s.Len(sender.toAddresses, 1) + s.Equal(discussionAuthor.Account().Email, sender.toAddresses[0].String()) + }) + + s.Run("author replies should NOT email discussion author", func() { + emailClient, sender := NewEmailClient() + + intake := s.createNewIntake() + + ctx, _ := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "this is a discussion
") + + s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + "banana
", + ) + + s.False(sender.emailWasSent) + }) + + s.Run("individual tags in replies should send an email to those users", func() { + emailClient, sender := NewEmailClient() + + intake, _ := s.createIntakeAndAddReviewersByEUAs("USR1", "BTMN", "ABCD") + + ctx, _ := s.getTestContextWithPrincipal("USR1", true) + discussionPost := s.createGRBDiscussion(ctx, emailClient, intake.ID, "this is a discussion
") + + replyContent := "banana
" + usersToEmail := s.getOrCreateUserAccts("BTMN", "ABCD") + for _, user := range usersToEmail { + replyContent = addTags( + replyContent, + models.Tag{ + TagType: models.TagTypeUserAccount, + TaggedContentID: user.ID, + }, + ) + } + s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + replyContent, + ) + + s.True(sender.emailWasSent) + s.Equal(sender.subject, fmt.Sprintf("You were tagged in a GRB Review discussion for %s", intake.ProjectName.String)) + // should exclude ABCD as author of reply and initial discussion post + s.Len(sender.bccAddresses, 2) + for _, user := range usersToEmail { + s.Contains(sender.bccAddresses, models.EmailAddress(user.Email)) + } + }) + s.Run("tagging groups in replies should send emails", func() { + emailClient, sender := NewEmailClient() + + intake, _ := s.createIntakeAndAddReviewersByEUAs("ABCD", "BTMN", "USR2") + + ctx, _ := s.getTestContextWithPrincipal("ABCD", false) + content := "banana
" + discussionPost := s.createGRBDiscussion( + ctx, + emailClient, + intake.ID, + content, + ) + + // author should be excluded from emails + reviewerAccts := s.getOrCreateUserAccts("BTMN", "USR2") + + content = addTags( + content, + models.Tag{ + TagType: models.TagTypeGroupGrbReviewers, + }, + models.Tag{ + TagType: models.TagTypeGroupItGov, + }, + ) + s.createGRBDiscussionReply( + ctx, + emailClient, + discussionPost, + content, + ) + + // should not send reply email as initial post and reply share an author + s.Len(sender.sentEmails, 2) + grtEmail, found := lo.Find(sender.sentEmails, func(email email.Email) bool { + return email.Subject == fmt.Sprintf("The Governance Admin Team was tagged in a GRB Review discussion for %s", intake.ProjectName.String) + }) + s.True(found) + s.Len(grtEmail.CcAddresses, 1) + s.Equal(grtEmail.CcAddresses[0], getTestEmailConfig().GRTEmail) + + grbEmail, found := lo.Find(sender.sentEmails, func(email email.Email) bool { + return email.Subject == fmt.Sprintf("The GRB was tagged in a GRB Review discussion for %s", intake.ProjectName.String) + }) + + s.True(found) + s.NotNil(grbEmail) + // should exclude ABCD as author of reply + s.Len(grbEmail.BccAddresses, len(reviewerAccts)) + for _, user := range reviewerAccts { + s.Contains(grbEmail.BccAddresses, models.EmailAddress(user.Email)) + } + }) +} + +// helper to create a discussion +func (s *ResolverSuite) createGRBDiscussion( + ctx context.Context, + emailClient *email.Client, + intakeID uuid.UUID, + content string, +) *models.SystemIntakeGRBReviewDiscussionPost { + taggedHTMLContent, err := models.NewTaggedHTMLFromString(content) + s.NoError(err) + discussion, err := CreateSystemIntakeGRBDiscussionPost( + ctx, + s.testConfigs.Store, + emailClient, + models.CreateSystemIntakeGRBDiscussionPostInput{ + SystemIntakeID: intakeID, + Content: taggedHTMLContent, + }, + ) + s.NotNil(discussion) + s.NoError(err) + s.Equal(discussion.Content, models.HTML(content)) + s.Equal(discussion.SystemIntakeID, intakeID) + s.NotNil(discussion.ID) + s.Nil(discussion.ReplyToID) + return discussion +} + +func (s *ResolverSuite) createGRBDiscussionReply( + ctx context.Context, + emailClient *email.Client, + discussionPost *models.SystemIntakeGRBReviewDiscussionPost, + content string, +) *models.SystemIntakeGRBReviewDiscussionPost { + taggedHTMLContent, err := models.NewTaggedHTMLFromString(content) + s.NoError(err) + replyPost, err := CreateSystemIntakeGRBDiscussionReply( + ctx, + s.testConfigs.Store, + emailClient, + models.CreateSystemIntakeGRBDiscussionReplyInput{ + InitialPostID: discussionPost.ID, + Content: taggedHTMLContent, + }, + ) + // test returned reply post + s.NotNil(replyPost) + s.NoError(err) + s.Equal(replyPost.Content, models.HTML(content)) + s.Equal(replyPost.SystemIntakeID, discussionPost.SystemIntakeID) + s.Equal(appcontext.Principal(ctx).Account().ID, replyPost.CreatedBy) + s.NotNil(replyPost.ReplyToID) + s.Equal(*replyPost.ReplyToID, discussionPost.ID) + return replyPost +} + +// helper to create an intake and add GRB reviewers at the same time +func (s *ResolverSuite) createIntakeAndAddReviewersByEUAs(reviewerEuaIDs ...string) (*models.SystemIntake, []*models.SystemIntakeGRBReviewer) { + intake := s.createNewIntake() + + reviewers := []*models.CreateGRBReviewerInput{} + for _, reviewerEUA := range reviewerEuaIDs { + reviewers = append(reviewers, &models.CreateGRBReviewerInput{ + EuaUserID: reviewerEUA, + VotingRole: models.SystemIntakeGRBReviewerVotingRoleVoting, + GrbRole: models.SystemIntakeGRBReviewerRoleCoChairCfo, + }) + } + + var createdReviewers []*models.SystemIntakeGRBReviewer + if len(reviewers) > 0 { + payload, err := CreateSystemIntakeGRBReviewers( + s.testConfigs.Context, + s.testConfigs.Store, + nil, //email client + userhelpers.GetUserInfoAccountInfosWrapperFunc(s.testConfigs.UserSearchClient.FetchUserInfos), + &models.CreateSystemIntakeGRBReviewersInput{ + SystemIntakeID: intake.ID, + Reviewers: reviewers, + }, + ) + s.NoError(err) + s.Equal(len(payload.Reviewers), len(reviewerEuaIDs)) + createdReviewers = payload.Reviewers + } + return intake, createdReviewers +} + +func addTags(htmlString string, tags ...models.Tag) string { + for _, tag := range tags { + span := fmt.Sprintf(`@tag` + htmlString += span + } + return htmlString +} diff --git a/pkg/graph/resolvers/system_intake_grb_reviewer.go b/pkg/graph/resolvers/system_intake_grb_reviewer.go index d85f3b5baa..543f1bdc5a 100644 --- a/pkg/graph/resolvers/system_intake_grb_reviewer.go +++ b/pkg/graph/resolvers/system_intake_grb_reviewer.go @@ -22,6 +22,18 @@ import ( "github.com/cms-enterprise/easi-app/pkg/userhelpers" ) +func SystemIntakeGRBDiscussions( + ctx context.Context, + store *storage.Store, + intakeID uuid.UUID, +) ([]*models.SystemIntakeGRBReviewDiscussion, error) { + posts, err := dataloaders.GetSystemIntakeGRBDiscussionPostsBySystemIntakeID(ctx, intakeID) + if err != nil { + return nil, err + } + return models.CreateGRBDiscussionsFromPosts(posts) +} + // CreateSystemIntakeGRBReviewers creates GRB Reviewers for a System Intake func CreateSystemIntakeGRBReviewers( ctx context.Context, @@ -55,8 +67,8 @@ func CreateSystemIntakeGRBReviewers( for _, acct := range accts { reviewerInput := reviewersByEUAMap[acct.Username] reviewer := models.NewSystemIntakeGRBReviewer(acct.ID, createdByID) - reviewer.VotingRole = models.SIGRBReviewerVotingRole(reviewerInput.VotingRole) - reviewer.GRBRole = models.SIGRBReviewerRole(reviewerInput.GrbRole) + reviewer.GRBVotingRole = reviewerInput.VotingRole + reviewer.GRBReviewerRole = reviewerInput.GrbRole reviewer.SystemIntakeID = input.SystemIntakeID reviewersToCreate = append(reviewersToCreate, reviewer) } @@ -144,8 +156,8 @@ func SystemIntakeCompareGRBReviewers( HasLoggedIn: comparison.HasLoggedIn, }, EuaUserID: comparison.EuaID, - VotingRole: models.SystemIntakeGRBReviewerVotingRole(comparison.VotingRole), - GrbRole: models.SystemIntakeGRBReviewerRole(comparison.GRBRole), + VotingRole: models.SystemIntakeGRBReviewerVotingRole(comparison.GRBVotingRole), + GrbRole: models.SystemIntakeGRBReviewerRole(comparison.GRBReviewerRole), IsCurrentReviewer: comparison.IsCurrentReviewer, } // Add the reviewer to the slice if an entry exists @@ -223,3 +235,17 @@ func StartGRBReview( return helpers.PointerTo("started GRB review"), nil }) } + +func GetPrincipalAsGRBReviewerBySystemIntakeID(ctx context.Context, systemIntakeID uuid.UUID) (*models.SystemIntakeGRBReviewer, error) { + principalUserAcctID := appcontext.Principal(ctx).Account().ID + grbReviewers, err := dataloaders.GetSystemIntakeGRBReviewersBySystemIntakeID(ctx, systemIntakeID) + if err != nil { + return nil, err + } + for _, reviewer := range grbReviewers { + if reviewer != nil && reviewer.UserID == principalUserAcctID { + return reviewer, nil + } + } + return nil, nil +} diff --git a/pkg/graph/resolvers/system_intake_grb_reviewer_test.go b/pkg/graph/resolvers/system_intake_grb_reviewer_test.go index a1aaabce6d..c37764246e 100644 --- a/pkg/graph/resolvers/system_intake_grb_reviewer_test.go +++ b/pkg/graph/resolvers/system_intake_grb_reviewer_test.go @@ -28,7 +28,7 @@ func (s *ResolverSuite) TestSystemIntakeGRBReviewer() { grbRole2 := models.SystemIntakeGRBReviewerRoleFedAdminBdgChair intake := s.createNewIntake() - userAccts := s.getOrCreateUserAccts([]string{reviewerEUA1, reviewerEUA2}) + userAccts := s.getOrCreateUserAccts(reviewerEUA1, reviewerEUA2) payload, err := CreateSystemIntakeGRBReviewers( ctx, @@ -54,12 +54,12 @@ func (s *ResolverSuite) TestSystemIntakeGRBReviewer() { reviewers := payload.Reviewers s.NoError(err) s.Equal(userAccts[0].ID, reviewers[0].UserID) - s.Equal(string(votingRole1), string(reviewers[0].VotingRole)) - s.Equal(string(grbRole1), string(reviewers[0].GRBRole)) + s.Equal(string(votingRole1), string(reviewers[0].GRBVotingRole)) + s.Equal(string(grbRole1), string(reviewers[0].GRBReviewerRole)) s.Equal(intake.ID, reviewers[0].SystemIntakeID) s.Equal(userAccts[1].ID, reviewers[1].UserID) - s.Equal(string(votingRole2), string(reviewers[1].VotingRole)) - s.Equal(string(grbRole2), string(reviewers[1].GRBRole)) + s.Equal(string(votingRole2), string(reviewers[1].GRBVotingRole)) + s.Equal(string(grbRole2), string(reviewers[1].GRBReviewerRole)) s.Equal(intake.ID, reviewers[1].SystemIntakeID) s.False(sender.emailWasSent) }) @@ -99,8 +99,8 @@ func (s *ResolverSuite) TestSystemIntakeGRBReviewer() { reviewerEUA := "ABCD" originalVotingRole := models.SystemIntakeGRBReviewerVotingRoleAlternate originalGRBRole := models.SystemIntakeGRBReviewerRoleAca3021Rep - newVotingRole := models.SIGRBRVRVoting - newGRBRole := models.SIGRBRRCMCSRep + newVotingRole := models.SystemIntakeGRBReviewerVotingRoleVoting + newGRBRole := models.SystemIntakeGRBReviewerRoleCmcsRep userAcct := s.getOrCreateUserAcct(reviewerEUA) intake, reviewer := s.createIntakeAndAddReviewer(&models.CreateGRBReviewerInput{ @@ -123,8 +123,8 @@ func (s *ResolverSuite) TestSystemIntakeGRBReviewer() { s.Equal(reviewer.ID, updatedReviewer.ID) s.Equal(reviewer.UserID, updatedReviewer.UserID) s.Equal(intake.ID, updatedReviewer.SystemIntakeID) - s.Equal(newVotingRole, updatedReviewer.VotingRole) - s.Equal(newGRBRole, updatedReviewer.GRBRole) + s.Equal(newVotingRole, updatedReviewer.GRBVotingRole) + s.Equal(newGRBRole, updatedReviewer.GRBReviewerRole) }) s.Run("delete GRB reviewer", func() { diff --git a/pkg/graph/schema.graphql b/pkg/graph/schema.graphql index 64c908337c..fd854f1f69 100644 --- a/pkg/graph/schema.graphql +++ b/pkg/graph/schema.graphql @@ -791,6 +791,10 @@ type SystemIntake { TRB Requests that share a CEDAR System or Contract Number """ relatedTRBRequests: [TRBRequest!]! + """ + GRB Review Discussion Posts/Threads + """ + grbDiscussions: [SystemIntakeGRBReviewDiscussion!]! } type SystemIntakeContractNumber { @@ -1226,6 +1230,34 @@ enum SystemIntakeGRBReviewerVotingRole { NON_VOTING } +type SystemIntakeGRBReviewDiscussionPost { + id: UUID! + content: HTML! + votingRole: SystemIntakeGRBReviewerVotingRole + grbRole: SystemIntakeGRBReviewerRole + systemIntakeID: UUID! + createdByUserAccount: UserAccount! + createdAt: Time! + modifiedByUserAccount: UserAccount + modifiedAt: Time +} + +type SystemIntakeGRBReviewDiscussion { + initialPost: SystemIntakeGRBReviewDiscussionPost! + replies: [SystemIntakeGRBReviewDiscussionPost!]! +} + + +input createSystemIntakeGRBDiscussionPostInput { + systemIntakeID: UUID! + content: TaggedHTML! +} + +input createSystemIntakeGRBDiscussionReplyInput { + initialPostID: UUID! + content: TaggedHTML! +} + """ Input data used to update the admin lead assigned to a system IT governance request @@ -1694,6 +1726,14 @@ type TRBRequestAttendee { modifiedAt: Time } +# lint-disable defined-types-are-used +enum TagType { + USER_ACCOUNT + GROUP_IT_GOV + GROUP_GRB_REVIEWERS +} +# lint-enable defined-types-are-used + """ The data needed add a TRB request attendee to a TRB request """ @@ -2519,6 +2559,9 @@ type Mutation { updateSystemIntakeGRBReviewer(input: UpdateSystemIntakeGRBReviewerInput!): SystemIntakeGRBReviewer! deleteSystemIntakeGRBReviewer(input: DeleteSystemIntakeGRBReviewerInput!): UUID! + createSystemIntakeGRBDiscussionPost(input: createSystemIntakeGRBDiscussionPostInput!): SystemIntakeGRBReviewDiscussionPost + createSystemIntakeGRBDiscussionReply(input: createSystemIntakeGRBDiscussionReplyInput!): SystemIntakeGRBReviewDiscussionPost + updateSystemIntakeLinkedCedarSystem(input: UpdateSystemIntakeLinkedCedarSystemInput!): UpdateSystemIntakePayload archiveSystemIntake(id: UUID!): SystemIntake! @@ -2669,6 +2712,10 @@ HTML are represented using as strings,Notification email """ scalar HTML +""" +TaggedHTML is represented using strings but can contain Tags (ex: @User) and possibly other richer elements than HTML +""" +scalar TaggedHTML """ Time values are represented as strings using RFC3339 format, for example 2019-10-12T07:20:50.52Z diff --git a/pkg/graph/schema.resolvers.go b/pkg/graph/schema.resolvers.go index 4c5bdd4219..e6acb712b8 100644 --- a/pkg/graph/schema.resolvers.go +++ b/pkg/graph/schema.resolvers.go @@ -668,6 +668,16 @@ func (r *mutationResolver) DeleteSystemIntakeGRBReviewer(ctx context.Context, in return input.ReviewerID, resolvers.DeleteSystemIntakeGRBReviewer(ctx, r.store, input.ReviewerID) } +// CreateSystemIntakeGRBDiscussionPost is the resolver for the createSystemIntakeGRBDiscussionPost field. +func (r *mutationResolver) CreateSystemIntakeGRBDiscussionPost(ctx context.Context, input models.CreateSystemIntakeGRBDiscussionPostInput) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + return resolvers.CreateSystemIntakeGRBDiscussionPost(ctx, r.store, r.emailClient, input) +} + +// CreateSystemIntakeGRBDiscussionReply is the resolver for the createSystemIntakeGRBDiscussionReply field. +func (r *mutationResolver) CreateSystemIntakeGRBDiscussionReply(ctx context.Context, input models.CreateSystemIntakeGRBDiscussionReplyInput) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + return resolvers.CreateSystemIntakeGRBDiscussionReply(ctx, r.store, r.emailClient, input) +} + // UpdateSystemIntakeLinkedCedarSystem is the resolver for the updateSystemIntakeLinkedCedarSystem field. func (r *mutationResolver) UpdateSystemIntakeLinkedCedarSystem(ctx context.Context, input models.UpdateSystemIntakeLinkedCedarSystemInput) (*models.UpdateSystemIntakePayload, error) { // If the linked system is not nil, make sure it's a valid CEDAR system, otherwise return an error @@ -1912,6 +1922,11 @@ func (r *systemIntakeResolver) RelatedTRBRequests(ctx context.Context, obj *mode return resolvers.SystemIntakeRelatedTRBRequests(ctx, obj.ID) } +// GrbDiscussions is the resolver for the grbDiscussions field. +func (r *systemIntakeResolver) GrbDiscussions(ctx context.Context, obj *models.SystemIntake) ([]*models.SystemIntakeGRBReviewDiscussion, error) { + return resolvers.SystemIntakeGRBDiscussions(ctx, r.store, obj.ID) +} + // DocumentType is the resolver for the documentType field. func (r *systemIntakeDocumentResolver) DocumentType(ctx context.Context, obj *models.SystemIntakeDocument) (*models.SystemIntakeDocumentType, error) { return &models.SystemIntakeDocumentType{ @@ -1951,12 +1966,12 @@ func (r *systemIntakeDocumentResolver) CanView(ctx context.Context, obj *models. // VotingRole is the resolver for the votingRole field. func (r *systemIntakeGRBReviewerResolver) VotingRole(ctx context.Context, obj *models.SystemIntakeGRBReviewer) (models.SystemIntakeGRBReviewerVotingRole, error) { - return models.SystemIntakeGRBReviewerVotingRole(obj.VotingRole), nil + return models.SystemIntakeGRBReviewerVotingRole(obj.GRBVotingRole), nil } // GrbRole is the resolver for the grbRole field. func (r *systemIntakeGRBReviewerResolver) GrbRole(ctx context.Context, obj *models.SystemIntakeGRBReviewer) (models.SystemIntakeGRBReviewerRole, error) { - return models.SystemIntakeGRBReviewerRole(obj.GRBRole), nil + return models.SystemIntakeGRBReviewerRole(obj.GRBReviewerRole), nil } // Author is the resolver for the author field. diff --git a/pkg/models/base_struct_user.go b/pkg/models/base_struct_user.go index 1c129140e6..1dc32c1a4c 100644 --- a/pkg/models/base_struct_user.go +++ b/pkg/models/base_struct_user.go @@ -8,7 +8,7 @@ import ( //TODO: This will replace base struct when the user table is fully implemented -// BaseStructUser represents the shared data in common betwen all models +// BaseStructUser represents the shared data in common between all models type BaseStructUser struct { ID uuid.UUID `json:"id" db:"id"` createdByRelation diff --git a/pkg/models/models_gen.go b/pkg/models/models_gen.go index 6b5202976b..cf6776dd14 100644 --- a/pkg/models/models_gen.go +++ b/pkg/models/models_gen.go @@ -616,6 +616,11 @@ type SystemIntakeFundingSourcesInput struct { FundingSources []*SystemIntakeFundingSourceInput `json:"fundingSources"` } +type SystemIntakeGRBReviewDiscussion struct { + InitialPost *SystemIntakeGRBReviewDiscussionPost `json:"initialPost"` + Replies []*SystemIntakeGRBReviewDiscussionPost `json:"replies"` +} + // Contains multiple system request collaborators, if any type SystemIntakeGovernanceTeam struct { IsPresent *bool `json:"isPresent,omitempty"` @@ -963,6 +968,16 @@ type UserError struct { Path []string `json:"path"` } +type CreateSystemIntakeGRBDiscussionPostInput struct { + SystemIntakeID uuid.UUID `json:"systemIntakeID"` + Content TaggedHTML `json:"content"` +} + +type CreateSystemIntakeGRBDiscussionReplyInput struct { + InitialPostID uuid.UUID `json:"initialPostID"` + Content TaggedHTML `json:"content"` +} + // A user role associated with a job code type Role string @@ -1349,3 +1364,46 @@ func (e *SystemIntakeStepToProgressTo) UnmarshalGQL(v interface{}) error { func (e SystemIntakeStepToProgressTo) MarshalGQL(w io.Writer) { fmt.Fprint(w, strconv.Quote(e.String())) } + +type TagType string + +const ( + TagTypeUserAccount TagType = "USER_ACCOUNT" + TagTypeGroupItGov TagType = "GROUP_IT_GOV" + TagTypeGroupGrbReviewers TagType = "GROUP_GRB_REVIEWERS" +) + +var AllTagType = []TagType{ + TagTypeUserAccount, + TagTypeGroupItGov, + TagTypeGroupGrbReviewers, +} + +func (e TagType) IsValid() bool { + switch e { + case TagTypeUserAccount, TagTypeGroupItGov, TagTypeGroupGrbReviewers: + return true + } + return false +} + +func (e TagType) String() string { + return string(e) +} + +func (e *TagType) UnmarshalGQL(v interface{}) error { + str, ok := v.(string) + if !ok { + return fmt.Errorf("enums must be strings") + } + + *e = TagType(str) + if !e.IsValid() { + return fmt.Errorf("%s is not a valid TagType", str) + } + return nil +} + +func (e TagType) MarshalGQL(w io.Writer) { + fmt.Fprint(w, strconv.Quote(e.String())) +} diff --git a/pkg/models/system_intake_grb_discussions.go b/pkg/models/system_intake_grb_discussions.go new file mode 100644 index 0000000000..8358dfeb7e --- /dev/null +++ b/pkg/models/system_intake_grb_discussions.go @@ -0,0 +1,91 @@ +package models + +import ( + "errors" + "slices" + + "github.com/google/uuid" +) + +type SystemIntakeGRBReviewDiscussionPost struct { + BaseStructUser + Content HTML `json:"content" db:"content"` + SystemIntakeID uuid.UUID `json:"systemIntakeId" db:"system_intake_id"` + ReplyToID *uuid.UUID `db:"reply_to_id"` + VotingRole *SystemIntakeGRBReviewerVotingRole `json:"votingRole" db:"voting_role"` + GRBRole *SystemIntakeGRBReviewerRole `json:"grbRole" db:"grb_role"` +} + +func NewSystemIntakeGRBReviewDiscussionPost(createdBy uuid.UUID) *SystemIntakeGRBReviewDiscussionPost { + return &SystemIntakeGRBReviewDiscussionPost{ + BaseStructUser: NewBaseStructUser(createdBy), + } +} + +// CreateGRBDiscussionsFromPosts sorts a slice of discussion posts (replies and initial) and sorts them into multiple discussions +func CreateGRBDiscussionsFromPosts(posts []*SystemIntakeGRBReviewDiscussionPost) ([]*SystemIntakeGRBReviewDiscussion, error) { + postMap := map[uuid.UUID][]*SystemIntakeGRBReviewDiscussionPost{} + for _, post := range posts { + // shouldn't happen but we deference below + if post == nil { + return nil, errors.New("post is nil") + } + // group posts into slices by the initial post id + var groupingID uuid.UUID + if post.ReplyToID != nil { + groupingID = *post.ReplyToID + } else { + groupingID = post.ID + } + posts, ok := postMap[groupingID] + if ok { + postMap[groupingID] = append(posts, post) + } else { + postMap[groupingID] = []*SystemIntakeGRBReviewDiscussionPost{post} + } + } + + // after grouping by initial post ID, loop through slice of slices and convert groups of posts into discussions + discussions := []*SystemIntakeGRBReviewDiscussion{} + for _, posts := range postMap { + groupedPosts, err := CreateGRBDiscussionFromPosts(posts) + if err != nil { + return nil, err + } + discussions = append(discussions, groupedPosts) + } + slices.SortFunc(discussions, func(a *SystemIntakeGRBReviewDiscussion, b *SystemIntakeGRBReviewDiscussion) int { + return a.InitialPost.CreatedAt.Compare(b.InitialPost.CreatedAt) + }) + return discussions, nil +} + +// CreateGRBDiscussionFromPosts organizes a post and its replies into a single discussion +// (for slices with multiple initial posts/related replies use CreateGRBDiscussionsFromPosts) +func CreateGRBDiscussionFromPosts(posts []*SystemIntakeGRBReviewDiscussionPost) (*SystemIntakeGRBReviewDiscussion, error) { + var discussion SystemIntakeGRBReviewDiscussion + for _, post := range posts { + if post.ReplyToID == nil { + if discussion.InitialPost != nil { + return nil, errors.New("posts must only contain one initial post") + } + discussion.InitialPost = post + } else { + discussion.Replies = append(discussion.Replies, post) + } + } + if discussion.InitialPost == nil { + return nil, errors.New("initial post not found") + } + slices.SortFunc(discussion.Replies, func(a *SystemIntakeGRBReviewDiscussionPost, b *SystemIntakeGRBReviewDiscussionPost) int { + return a.CreatedAt.Compare(b.CreatedAt) + }) + return &discussion, nil +} + +func (r SystemIntakeGRBReviewDiscussionPost) GetMappingKey() uuid.UUID { + return r.SystemIntakeID +} +func (r SystemIntakeGRBReviewDiscussionPost) GetMappingVal() *SystemIntakeGRBReviewDiscussionPost { + return &r +} diff --git a/pkg/models/system_intake_grb_discussions_test.go b/pkg/models/system_intake_grb_discussions_test.go new file mode 100644 index 0000000000..5f2a37e9c9 --- /dev/null +++ b/pkg/models/system_intake_grb_discussions_test.go @@ -0,0 +1,219 @@ +package models + +import ( + "github.com/google/uuid" +) + +func (s *ModelTestSuite) TestSystemIntakeGRBDiscussionsHelpers() { + createdByID := uuid.New() + + // initial post + post1 := NewSystemIntakeGRBReviewDiscussionPost(createdByID) + post1ID := uuid.New() + post1.ID = post1ID + + // replies + post1reply1 := NewSystemIntakeGRBReviewDiscussionPost(createdByID) + post1reply1ID := uuid.New() + post1reply1.ID = post1reply1ID + post1reply1.ReplyToID = &post1ID + post1reply2 := NewSystemIntakeGRBReviewDiscussionPost(createdByID) + post1reply2ID := uuid.New() + post1reply2.ID = post1reply2ID + post1reply2.ReplyToID = &post1ID + + // initial post + post2 := NewSystemIntakeGRBReviewDiscussionPost(createdByID) + post2ID := uuid.New() + post2.ID = post2ID + + // replies + post2reply1 := NewSystemIntakeGRBReviewDiscussionPost(createdByID) + post2reply1ID := uuid.New() + post2reply1.ID = post2reply1ID + post2reply1.ReplyToID = &post2ID + post2reply2 := NewSystemIntakeGRBReviewDiscussionPost(createdByID) + post2reply2ID := uuid.New() + post2reply2.ID = post2reply2ID + post2reply2.ReplyToID = &post2ID + + s.Run("CreateGRBDiscussionsFromPosts should sort initial posts and replies", func() { + // randomized order to reflect the possibility of posts not coming sorted from db + posts := []*SystemIntakeGRBReviewDiscussionPost{ + post2, + post1reply1, + post1, + post1reply2, + post2reply2, + post2reply1, + } + + discussions, err := CreateGRBDiscussionsFromPosts(posts) + s.NoError(err) + + s.NotNil(discussions) + s.Len(discussions, 2) + + // first discussion tests + firstDisc := discussions[0] + s.EqualValues( + firstDisc.InitialPost.ID, + post1ID, + "first discussion should be sorted as first item in slice", + ) + + // replies to first discussion + s.Len(firstDisc.Replies, 2) + + firstDiscFirstReply := firstDisc.Replies[0] + s.EqualValues( + firstDiscFirstReply.ID, + post1reply1ID, + "first reply to first discussion should be sorted as first reply", + ) + s.NotNil(firstDiscFirstReply.ReplyToID) + s.EqualValues( + *firstDiscFirstReply.ReplyToID, + post1ID, + "first reply to first discussion should have replyToID of first discussion post", + ) + + firstDiscSecondReply := firstDisc.Replies[1] + s.EqualValues( + firstDiscSecondReply.ID, + post1reply2ID, + "second reply to first discussion should be sorted as second reply", + ) + s.NotNil(firstDiscSecondReply.ReplyToID) + s.EqualValues( + *firstDiscSecondReply.ReplyToID, + post1ID, + "second reply to first discussion should have replyToID of first discussion post", + ) + + // second discussion tests + secondDisc := discussions[1] + s.EqualValues( + secondDisc.InitialPost.ID, + post2ID, + "second discussion should be sorted as second item in slice", + ) + + // replies to second discussion + s.Len(secondDisc.Replies, 2) + + secondDiscFirstReply := secondDisc.Replies[0] + s.EqualValues( + secondDiscFirstReply.ID, + post2reply1ID, + "first reply to second discussion should be sorted as first reply", + ) + s.NotNil(secondDiscFirstReply.ReplyToID) + s.EqualValues( + *secondDiscFirstReply.ReplyToID, + post2ID, + "first reply to second discussion should have replyToID of second discussion post", + ) + + secondDiscSecondReply := secondDisc.Replies[1] + s.EqualValues( + secondDiscSecondReply.ID, + post2reply2ID, + "second reply to second discussion should be sorted as second reply", + ) + s.NotNil(secondDiscSecondReply.ReplyToID) + s.EqualValues( + *secondDiscSecondReply.ReplyToID, + post2ID, + "second reply to second discussion should have replyToID of second discussion post", + ) + }) + + s.Run("CreateGRBDiscussionFromPosts should sort initial posts and replies", func() { + // randomized order to reflect the possibility of posts not coming sorted from db + posts := []*SystemIntakeGRBReviewDiscussionPost{ + post1reply1, + post1, + post1reply2, + } + + discussion, err := CreateGRBDiscussionFromPosts(posts) + s.NoError(err) + s.NotNil(discussion) + + s.EqualValues( + discussion.InitialPost.ID, + post1ID, + ) + + firstReply := discussion.Replies[0] + s.EqualValues( + firstReply.ID, + post1reply1ID, + "first reply to discussion should be sorted as first reply", + ) + s.NotNil(firstReply.ReplyToID) + s.EqualValues( + *firstReply.ReplyToID, + post1ID, + "first reply to discussion should have replyToID of discussion post", + ) + + secondReply := discussion.Replies[1] + s.EqualValues( + secondReply.ID, + post1reply2ID, + "second reply to discussion should be sorted as second reply", + ) + s.NotNil(secondReply.ReplyToID) + s.EqualValues( + *secondReply.ReplyToID, + post1ID, + "second reply to discussion should have replyToID of discussion post", + ) + }) + + s.Run("CreateGRBDiscussionFromPosts should error if two initial posts are found", func() { + // randomized order to reflect the possibility of posts not coming sorted from db + posts := []*SystemIntakeGRBReviewDiscussionPost{ + post2, + post1reply1, + post1, + post1reply2, + post2reply2, + post2reply1, + } + + discussion, err := CreateGRBDiscussionFromPosts(posts) + s.Error(err) + s.Nil(discussion) + }) + + s.Run("CreateGRBDiscussionFromPosts should error if no initial posts are found", func() { + // randomized order to reflect the possibility of posts not coming sorted from db + posts := []*SystemIntakeGRBReviewDiscussionPost{ + post1reply1, + post1reply2, + post2reply2, + post2reply1, + } + + discussion, err := CreateGRBDiscussionFromPosts(posts) + s.Error(err) + s.Nil(discussion) + }) + + s.Run("CreateGRBDiscussionsFromPosts should error if no initial posts are found", func() { + // randomized order to reflect the possibility of posts not coming sorted from db + posts := []*SystemIntakeGRBReviewDiscussionPost{ + post1reply1, + post1reply2, + post2reply2, + post2reply1, + } + + discussions, err := CreateGRBDiscussionsFromPosts(posts) + s.Error(err) + s.Nil(discussions) + }) +} diff --git a/pkg/models/system_intake_grb_reviewers.go b/pkg/models/system_intake_grb_reviewers.go index 04aa88a167..9ce6597708 100644 --- a/pkg/models/system_intake_grb_reviewers.go +++ b/pkg/models/system_intake_grb_reviewers.go @@ -1,6 +1,7 @@ package models import ( + "fmt" "time" "github.com/google/uuid" @@ -8,38 +9,48 @@ import ( "github.com/cms-enterprise/easi-app/pkg/authentication" ) -type SIGRBReviewerRole string - -const ( - SIGRBRRCoChairCIO SIGRBReviewerRole = "CO_CHAIR_CIO" - SIGRBRRCoChairCFO SIGRBReviewerRole = "CO_CHAIR_CFO" - SIGRBRRCoChairHCA SIGRBReviewerRole = "CO_CHAIR_HCA" - SIGRBRRACA3021Rep SIGRBReviewerRole = "ACA_3021_REP" - SIGRBRRCCIIORep SIGRBReviewerRole = "CCIIO_REP" - SIGRBRRProgOpBDGChair SIGRBReviewerRole = "PROGRAM_OPERATIONS_BDG_CHAIR" - SIGRBRRCMCSRep SIGRBReviewerRole = "CMCS_REP" - SIGRBRRFedAdminBDGChair SIGRBReviewerRole = "FED_ADMIN_BDG_CHAIR" - SIGRBRRProgIntBDGChair SIGRBReviewerRole = "PROGRAM_INTEGRITY_BDG_CHAIR" - SIGRBRRQIORep SIGRBReviewerRole = "QIO_REP" - SIGRBRRSubjectMatterExpert SIGRBReviewerRole = "SUBJECT_MATTER_EXPERT" - SIGRBRROther SIGRBReviewerRole = "OTHER" -) - -type SIGRBReviewerVotingRole string +func (r SystemIntakeGRBReviewerVotingRole) Humanize() (string, error) { + var grbVotingRoleTranslationsMap = map[SystemIntakeGRBReviewerVotingRole]string{ + SystemIntakeGRBReviewerVotingRoleVoting: "Voting", + SystemIntakeGRBReviewerVotingRoleNonVoting: "Non-voting", + SystemIntakeGRBReviewerVotingRoleAlternate: "Alternate", + } + translation, ok := grbVotingRoleTranslationsMap[r] + if !ok { + return "", fmt.Errorf("%s is not a valid SIGRBReviewerVotingRole", r) + } + return translation, nil +} -const ( - SIGRBRVRVoting SIGRBReviewerVotingRole = "VOTING" - SIGRBRVRNonVoting SIGRBReviewerVotingRole = "NON_VOTING" - SIGRBRVRAlternate SIGRBReviewerVotingRole = "ALTERNATE" -) +func (r SystemIntakeGRBReviewerRole) Humanize() (string, error) { + var grbRoleTranslationsMap = map[SystemIntakeGRBReviewerRole]string{ + SystemIntakeGRBReviewerRoleCoChairCio: "Co-Chair - CIO", + SystemIntakeGRBReviewerRoleCoChairCfo: "CO-Chair - CFO", + SystemIntakeGRBReviewerRoleCoChairHca: "CO-Chair - HCA", + SystemIntakeGRBReviewerRoleAca3021Rep: "ACA 3021 Rep", + SystemIntakeGRBReviewerRoleCciioRep: "CCIIO Rep", + SystemIntakeGRBReviewerRoleProgramOperationsBdgChair: "Program Operations BDG Chair", + SystemIntakeGRBReviewerRoleCmcsRep: "CMCS Rep", + SystemIntakeGRBReviewerRoleFedAdminBdgChair: "Fed Admin BDG Chair", + SystemIntakeGRBReviewerRoleProgramIntegrityBdgChair: "Program Integrity BDG Chair", + SystemIntakeGRBReviewerRoleQioRep: "QIO Rep", + SystemIntakeGRBReviewerRoleSubjectMatterExpert: "Subject Matter Expert (SME)", + SystemIntakeGRBReviewerRoleOther: "Other", + } + translation, ok := grbRoleTranslationsMap[r] + if !ok { + return "", fmt.Errorf("%s is not a valid SIGRBReviewerRole", r) + } + return translation, nil +} // SystemIntakeGRBReviewer describes type SystemIntakeGRBReviewer struct { BaseStructUser userIDRelation - SystemIntakeID uuid.UUID `json:"systemIntakeId" db:"system_intake_id"` - VotingRole SIGRBReviewerVotingRole `json:"votingRole" db:"voting_role"` - GRBRole SIGRBReviewerRole `json:"grbRole" db:"grb_role"` + SystemIntakeID uuid.UUID `json:"systemIntakeId" db:"system_intake_id"` + GRBVotingRole SystemIntakeGRBReviewerVotingRole `json:"votingRole" db:"voting_role"` + GRBReviewerRole SystemIntakeGRBReviewerRole `json:"grbRole" db:"grb_role"` } func NewSystemIntakeGRBReviewer(userID uuid.UUID, createdBy uuid.UUID) *SystemIntakeGRBReviewer { diff --git a/pkg/models/tag.go b/pkg/models/tag.go new file mode 100644 index 0000000000..2e0e76db38 --- /dev/null +++ b/pkg/models/tag.go @@ -0,0 +1,12 @@ +package models + +import ( + "github.com/google/uuid" +) + +// Tag represents a tagged item in HTML +type Tag struct { + // BaseStructUser // TODO Introduce again if we store tags in the database + TagType TagType `json:"tagType" db:"tag_type"` + TaggedContentID uuid.UUID `json:"taggedContentID" db:"tagged_content_id"` +} diff --git a/pkg/models/tagged_html.go b/pkg/models/tagged_html.go new file mode 100644 index 0000000000..eef678da48 --- /dev/null +++ b/pkg/models/tagged_html.go @@ -0,0 +1,188 @@ +package models + +import ( + "context" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "html/template" + "io" + "regexp" + + "github.com/google/uuid" + "github.com/samber/lo" + + "github.com/cms-enterprise/easi-app/pkg/appcontext" + "github.com/cms-enterprise/easi-app/pkg/sanitization" +) + +var ( + spanRe = regexp.MustCompile(`]*>.*?`) + attributeRe = regexp.MustCompile(`(\S+)="([^"]+)"`) +) + +// TaggedHTML Is the input type for HTML that could contain tags +type TaggedHTML struct { + RawContent HTML + Tags []*Tag +} + +// UnmarshalGQLContext unmarshals the data from graphql to the TaggedHTML type +func (th *TaggedHTML) UnmarshalGQLContext(_ context.Context, v interface{}) error { + rawHTML, ok := v.(string) + if !ok { + return errors.New("invalid TaggedHTML") + } + + tc, err := NewTaggedHTMLFromString(rawHTML) + if err != nil { + return err + } + *th = TaggedHTML(tc) + + return nil +} + +// MarshalGQLContext marshals the TaggedHTML type to JSON to return to graphQL +func (th TaggedHTML) MarshalGQLContext(ctx context.Context, w io.Writer) error { + logger := appcontext.ZLogger(ctx) + + jsonValue, err := json.Marshal(th.RawContent) + if err != nil { + logger.Info("invalid TaggedHTML") + return fmt.Errorf("failed to marshal TaggedHTMLto JSON: %w", err) + } + + if _, err := w.Write(jsonValue); err != nil { + return fmt.Errorf("failed to write TaggedHTML to writer: %w", err) + } + + return nil +} + +func (th TaggedHTML) UniqueTags() []*Tag { + uniqueTags := lo.UniqBy(th.Tags, func(tag *Tag) string { + return fmt.Sprint(tag.TagType, tag.TaggedContentID.String()) + }) + + return uniqueTags +} + +// NewTaggedHTMLFromString converts an htmlString into TaggedHTML. It will store the input string as the raw content, +// and then sanitize and parse the input. +func NewTaggedHTMLFromString(htmlString string) (TaggedHTML, error) { + // sanitize + htmlString = sanitization.SanitizeTaggedHTML(htmlString) + tags, err := tagsFromStringRegex(htmlString) + if err != nil { + return TaggedHTML{}, err + } + + return TaggedHTML{ + RawContent: HTML(htmlString), + Tags: tags, + }, nil +} + +func tagsFromStringRegex(htmlString string) ([]*Tag, error) { + tagStrings := spanRe.FindAllString(htmlString, -1) + + var tags []*Tag + for _, tagString := range tagStrings { + parsedTag, err := parseTagRegEx(tagString) + if err != nil { + return nil, err + } + + tags = append(tags, &parsedTag) + } + + return tags, nil +} + +func parseTagRegEx(tag string) (Tag, error) { + attributes := extractAttributes(tag) + + tagType := TagType(attributes["tag-type"]) + if !tagType.IsValid() { + return Tag{}, fmt.Errorf("%s is not a valid tag type", tagType) + } + + class := attributes["class"] + if class != "mention" { + return Tag{}, fmt.Errorf("%s is not a valid class for tag", class) + } + + // if tag type is NOT a user account, there will be no UUID to parse + if tagType != TagTypeUserAccount { + return Tag{ + TagType: tagType, + }, nil + } + + id := attributes["data-id-db"] + if len(id) < 1 { + return Tag{}, errors.New("missing data-id-db in tag") + } + + parsed, err := uuid.Parse(id) + if err != nil { + return Tag{}, fmt.Errorf("failed to prase UUID when parsing tags: %w", err) + } + + return Tag{ + TagType: tagType, + TaggedContentID: parsed, + }, nil +} + +func extractAttributes(match string) map[string]string { + attributeMatches := attributeRe.FindAllStringSubmatch(match, -1) + + // Create a map to store the attribute key-value pairs + attributes := map[string]string{} + + // Iterate over the matches and extract the attribute name and value + for _, attributeMatch := range attributeMatches { + if len(attributeMatch) < 3 { + continue + } + + attributeName := attributeMatch[1] + attributeValue := attributeMatch[2] + + attributes[attributeName] = attributeValue + } + + return attributes +} + +func (th *TaggedHTML) Scan(src interface{}) error { + switch t := src.(type) { + case string: + tagHTML, err := NewTaggedHTMLFromString(t) + if err != nil { + return err + } + *th = tagHTML + + case []byte: + tagHTML, err := NewTaggedHTMLFromString(string(t)) + if err != nil { + return err + } + *th = tagHTML + } + + return nil +} + +func (th TaggedHTML) Value() (driver.Value, error) { + return string(th.RawContent), nil +} + +func (th TaggedHTML) ToTemplate() template.HTML { + sanitized := sanitization.SanitizeTaggedHTML(th.RawContent) + return template.HTML(sanitized) //nolint +} diff --git a/pkg/models/tagged_html_test.go b/pkg/models/tagged_html_test.go new file mode 100644 index 0000000000..14b3e8b496 --- /dev/null +++ b/pkg/models/tagged_html_test.go @@ -0,0 +1,67 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExtractHTMLSpansRegex(t *testing.T) { + htmlMention := `
Hey @Alexander Stark! Will you be able to join the meeting next week? If not, can you contact @Terry Thompson to let them know?
` + mentionNodes, err := tagsFromStringRegex(htmlMention) + assert.NotNil(t, mentionNodes) + assert.NoError(t, err) + + assert.Len(t, mentionNodes, 2) + +} + +// TestExtractHTMLSpansRegexWithoutMentions ensures that we don't attempt to parse span elements +// that don't have a data-type="mention" attribute +func TestExtractHTMLSpansRegexWithoutMentions(t *testing.T) { + // Check case with NO mentions + html := `Hey @Alexander Stark! Will you be able to join the meeting next week? If not, can you contact @Terry Thompson to let them know?
` + mentionNodes, err := tagsFromStringRegex(html) + // Make sure we don't get an error, and that we get no results back + assert.NoError(t, err) + assert.Len(t, mentionNodes, 0) + + // Check case with 2 spans, but only one is a mention + html = `Hey @Alexander Stark! Will you be able to join the meeting next week? If not, can you contact @Terry Thompson to let them know?
` + mentionNodes, err = tagsFromStringRegex(html) + // Make sure we get an error. Any bad tags should fail the entire parse + assert.Error(t, err) + assert.Len(t, mentionNodes, 0) + + // Redacted Example from ECHIMP + html = `On December X, 20XX, we issued CR XXXXXX, Medicare Administrative Contractors (MACs) Updating Their Systems to Integrate with REDACTED, explaining the implementation process for the REDACTED (XXX) provider telephone survey. According to the CR’s Attachment B, Estimated Implementation Schedule, there is a 12 to 16 week process from design to go-live for each MAC. , is not a valid value for TagType error parsing html mention We plan to implement this CR using a phased approach, in this order:` + mentionNodes, err = tagsFromStringRegex(html) + // Make sure we get an error back as spans are only allowed when they are tags + assert.Error(t, err) + assert.Len(t, mentionNodes, 0) +} + +func TestHTMLMentionFromString(t *testing.T) { + tag1UUID := "4744bb54-c7d3-485a-9985-fdab4b86d28d" + tag1Name := "Alexander Stark" + tag1Type := TagTypeUserAccount + tag1 := `@` + tag1Name + `` + tag2UUID := "ab2b16ef-1928-4788-ad9c-263300ace094" + tag2Name := "Terry Thompson" + tag2Type := TagTypeUserAccount + tag2 := `@` + tag2Name + `` + tag3Name := "Salesforce" + tag3Type := TagTypeGroupGrbReviewers + tag3 := `@` + tag3Name + `` + htmlMention := `Hey ` + tag1 + `! Will you be able to join the meeting next week? If not, can you contact ` + tag2 + ` to let them know?
We are planning on using the ` + tag3 + `solution.` + taggedHTML, err := NewTaggedHTMLFromString(htmlMention) + assert.NoError(t, err) + assert.Len(t, taggedHTML.Tags, 3) + + mention1 := taggedHTML.Tags[0] + mention2 := taggedHTML.Tags[1] + mention3 := taggedHTML.Tags[2] + assert.EqualValues(t, tag1Type, mention1.TagType) + assert.EqualValues(t, tag2Type, mention2.TagType) + assert.EqualValues(t, tag3Type, mention3.TagType) +} diff --git a/pkg/sanitization/html.go b/pkg/sanitization/html.go index 79e0bc6539..2e8afab3ee 100644 --- a/pkg/sanitization/html.go +++ b/pkg/sanitization/html.go @@ -6,9 +6,10 @@ import ( "github.com/microcosm-cc/bluemonday" ) -var once sync.Once - -var htmlSanitizerPolicy *bluemonday.Policy +var ( + htmlInitOnce sync.Once + htmlSanitizerPolicy *bluemonday.Policy +) // SanitizeHTML takes a string representation of HTML and sanitizes it func SanitizeHTML[stringType ~string](input stringType) stringType { @@ -19,13 +20,9 @@ func SanitizeHTML[stringType ~string](input stringType) stringType { return stringType(output) } -// getHTMLSanitizerPolicy returns the sanitization policy for HTML func getHTMLSanitizerPolicy() *bluemonday.Policy { - - // once ensures that a policy is instantiated once. Otherwise, it is just retrieved. - once.Do(func() { - policy := createHTMLPolicy() - htmlSanitizerPolicy = policy + htmlInitOnce.Do(func() { + htmlSanitizerPolicy = createHTMLPolicy() }) return htmlSanitizerPolicy diff --git a/pkg/sanitization/tagged_html.go b/pkg/sanitization/tagged_html.go new file mode 100644 index 0000000000..2673af6e93 --- /dev/null +++ b/pkg/sanitization/tagged_html.go @@ -0,0 +1,36 @@ +package sanitization + +import ( + "sync" + + "github.com/microcosm-cc/bluemonday" +) + +var ( + taggedHTMLInitOnce sync.Once + taggedHTMLPolicy *bluemonday.Policy +) + +func SanitizeTaggedHTML[stringType ~string](input stringType) stringType { + policy := getTaggedHTMLPolicy() + + output := policy.Sanitize(string(input)) + + return stringType(output) +} + +func getTaggedHTMLPolicy() *bluemonday.Policy { + taggedHTMLInitOnce.Do(func() { + taggedHTMLPolicy = createTaggedHTMLPolicy() + }) + + return taggedHTMLPolicy +} + +func createTaggedHTMLPolicy() *bluemonday.Policy { + policy := bluemonday.NewPolicy() + // rules for tags + policy.AllowElements("span", "p") + policy.AllowAttrs("data-type", "class", "tag-type", "data-id-db", "data-label").OnElements("span") + return policy +} diff --git a/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_id_internal.sql b/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_id_internal.sql new file mode 100644 index 0000000000..c8367fe014 --- /dev/null +++ b/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_id_internal.sql @@ -0,0 +1,13 @@ +SELECT + id, + content, + voting_role, + grb_role, + system_intake_id, + reply_to_id, + modified_at, + modified_by, + created_at, + created_by +FROM system_intake_internal_grb_review_discussion_posts +WHERE id = :id; diff --git a/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_intake_ids_internal.sql b/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_intake_ids_internal.sql new file mode 100644 index 0000000000..6ddf475ab5 --- /dev/null +++ b/pkg/sqlqueries/SQL/system_intake_grb_discussions/get_by_intake_ids_internal.sql @@ -0,0 +1,13 @@ +SELECT + id, + content, + voting_role, + grb_role, + system_intake_id, + reply_to_id, + modified_at, + modified_by, + created_at, + created_by +FROM system_intake_internal_grb_review_discussion_posts +WHERE system_intake_id = ANY(:system_intake_ids); diff --git a/pkg/sqlqueries/SQL/system_intake_grb_discussions/insert_internal.sql b/pkg/sqlqueries/SQL/system_intake_grb_discussions/insert_internal.sql new file mode 100644 index 0000000000..c5c424e371 --- /dev/null +++ b/pkg/sqlqueries/SQL/system_intake_grb_discussions/insert_internal.sql @@ -0,0 +1,34 @@ +INSERT INTO system_intake_internal_grb_review_discussion_posts ( + id, + content, + voting_role, + grb_role, + system_intake_id, + reply_to_id, + created_by, + created_at, + modified_by, + modified_at +) VALUES ( + :id, + :content, + :voting_role, + :grb_role, + :system_intake_id, + :reply_to_id, + :created_by, + :created_at, + :modified_by, + :modified_at +) +RETURNING + id, + content, + voting_role, + grb_role, + system_intake_id, + reply_to_id, + created_by, + created_at, + modified_by, + modified_at; diff --git a/pkg/sqlqueries/system_intake_grb_discussions.go b/pkg/sqlqueries/system_intake_grb_discussions.go new file mode 100644 index 0000000000..7301212054 --- /dev/null +++ b/pkg/sqlqueries/system_intake_grb_discussions.go @@ -0,0 +1,25 @@ +package sqlqueries + +import _ "embed" + +//go:embed SQL/system_intake_grb_discussions/insert_internal.sql +var insertSystemIntakeGRBDiscussionSQL string + +//go:embed SQL/system_intake_grb_discussions/get_by_id_internal.sql +var getSystemIntakeGRBDiscussionsByIDSQL string + +//go:embed SQL/system_intake_grb_discussions/get_by_intake_ids_internal.sql +var getSystemIntakeGRBDiscussionsByIntakeIDsSQL string + +// SystemIntakeGRBDiscussion holds all SQL scripts for GRB Discussions +var SystemIntakeGRBDiscussion = systemIntakeGRBDiscussionScripts{ + Create: insertSystemIntakeGRBDiscussionSQL, + GetBySystemIntakeIDs: getSystemIntakeGRBDiscussionsByIntakeIDsSQL, + GetByID: getSystemIntakeGRBDiscussionsByIDSQL, +} + +type systemIntakeGRBDiscussionScripts struct { + Create string + GetByID string + GetBySystemIntakeIDs string +} diff --git a/pkg/sqlqueries/system_intake_grb_reviewers.go b/pkg/sqlqueries/system_intake_grb_reviewers.go index ed089d2fc6..9495aeec12 100644 --- a/pkg/sqlqueries/system_intake_grb_reviewers.go +++ b/pkg/sqlqueries/system_intake_grb_reviewers.go @@ -2,8 +2,6 @@ package sqlqueries import _ "embed" -// deleteSystemIntakeSystemsSQL holds the SQL command to remove all linked systems from a System Intake -// //go:embed SQL/system_intake_grb_reviewer/insert.sql var insertSystemIntakeGRBReviewerSQL string diff --git a/pkg/storage/system_intake_grb_discussions.go b/pkg/storage/system_intake_grb_discussions.go new file mode 100644 index 0000000000..9fc59fe710 --- /dev/null +++ b/pkg/storage/system_intake_grb_discussions.go @@ -0,0 +1,41 @@ +package storage + +import ( + "context" + + "github.com/google/uuid" + "github.com/lib/pq" + + "github.com/cms-enterprise/easi-app/pkg/models" + "github.com/cms-enterprise/easi-app/pkg/sqlqueries" + "github.com/cms-enterprise/easi-app/pkg/sqlutils" +) + +func (s *Store) CreateSystemIntakeGRBDiscussionPost(ctx context.Context, np sqlutils.NamedPreparer, post *models.SystemIntakeGRBReviewDiscussionPost) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + if post.ID == uuid.Nil { + post.ID = uuid.New() + } + createdPost := &models.SystemIntakeGRBReviewDiscussionPost{} + return createdPost, namedGet(ctx, np, createdPost, sqlqueries.SystemIntakeGRBDiscussion.Create, post) +} + +func (s *Store) GetSystemIntakeGRBDiscussionPostByID( + ctx context.Context, + np sqlutils.NamedPreparer, + discussionID uuid.UUID, +) (*models.SystemIntakeGRBReviewDiscussionPost, error) { + post := &models.SystemIntakeGRBReviewDiscussionPost{} + return post, namedGet(ctx, np, post, sqlqueries.SystemIntakeGRBDiscussion.GetByID, args{ + "id": discussionID, + }) +} + +func (s *Store) SystemIntakeGRBDiscussionPostsBySystemIntakeIDs( + ctx context.Context, + systemIntakeIDs []uuid.UUID, +) ([]*models.SystemIntakeGRBReviewDiscussionPost, error) { + posts := []*models.SystemIntakeGRBReviewDiscussionPost{} + return posts, namedSelect(ctx, s, &posts, sqlqueries.SystemIntakeGRBDiscussion.GetBySystemIntakeIDs, args{ + "system_intake_ids": pq.Array(systemIntakeIDs), + }) +} diff --git a/pkg/storage/system_intake_grb_reviewer.go b/pkg/storage/system_intake_grb_reviewer.go index 5f3d25b5f7..bf8460557f 100644 --- a/pkg/storage/system_intake_grb_reviewer.go +++ b/pkg/storage/system_intake_grb_reviewer.go @@ -71,8 +71,14 @@ func (s *Store) DeleteSystemIntakeGRBReviewer(ctx context.Context, tx *sqlx.Tx, } func (s *Store) SystemIntakeGRBReviewersBySystemIntakeIDs(ctx context.Context, systemIntakeIDs []uuid.UUID) ([]*models.SystemIntakeGRBReviewer, error) { + return sqlutils.WithTransactionRet[[]*models.SystemIntakeGRBReviewer](ctx, s, func(tx *sqlx.Tx) ([]*models.SystemIntakeGRBReviewer, error) { + return s.SystemIntakeGRBReviewersBySystemIntakeIDsNP(ctx, tx, systemIntakeIDs) + }) +} + +func (s *Store) SystemIntakeGRBReviewersBySystemIntakeIDsNP(ctx context.Context, np sqlutils.NamedPreparer, systemIntakeIDs []uuid.UUID) ([]*models.SystemIntakeGRBReviewer, error) { var systemIntakeGRBReviewers []*models.SystemIntakeGRBReviewer - return systemIntakeGRBReviewers, namedSelect(ctx, s.db, &systemIntakeGRBReviewers, sqlqueries.SystemIntakeGRBReviewer.GetBySystemIntakeID, args{ + return systemIntakeGRBReviewers, namedSelect(ctx, np, &systemIntakeGRBReviewers, sqlqueries.SystemIntakeGRBReviewer.GetBySystemIntakeID, args{ "system_intake_ids": pq.Array(systemIntakeIDs), }) } diff --git a/pkg/storage/truncate.go b/pkg/storage/truncate.go index d2f3676242..c265854240 100644 --- a/pkg/storage/truncate.go +++ b/pkg/storage/truncate.go @@ -26,6 +26,7 @@ func (s *Store) TruncateAllTablesDANGEROUS(logger *zap.Logger) error { system_intake_documents, system_intake_funding_sources, system_intake_grb_reviewers, + system_intake_internal_grb_review_discussion_posts, system_intake_systems, system_intakes, trb_admin_notes_trb_request_documents_links, diff --git a/pkg/storage/user_account_store.go b/pkg/storage/user_account_store.go index 0fd1188491..a5a454b0a7 100644 --- a/pkg/storage/user_account_store.go +++ b/pkg/storage/user_account_store.go @@ -4,8 +4,10 @@ import ( "context" "database/sql" _ "embed" + "errors" "github.com/google/uuid" + "github.com/jmoiron/sqlx" "github.com/lib/pq" "github.com/cms-enterprise/easi-app/pkg/authentication" @@ -80,7 +82,7 @@ func (s *Store) UserAccountGetByID(ctx context.Context, np sqlutils.NamedPrepare }) if err != nil { - if err == sql.ErrNoRows { + if errors.Is(err, sql.ErrNoRows) { return nil, nil } return nil, err @@ -91,8 +93,14 @@ func (s *Store) UserAccountGetByID(ctx context.Context, np sqlutils.NamedPrepare // UserAccountsByIDs gets user accounts by user ID func (s *Store) UserAccountsByIDs(ctx context.Context, userIDs []uuid.UUID) ([]*authentication.UserAccount, error) { + return sqlutils.WithTransactionRet[[]*authentication.UserAccount](ctx, s, func(tx *sqlx.Tx) ([]*authentication.UserAccount, error) { + return s.UserAccountsByIDsNP(ctx, tx, userIDs) + }) +} + +func (s *Store) UserAccountsByIDsNP(ctx context.Context, np sqlutils.NamedPreparer, userIDs []uuid.UUID) ([]*authentication.UserAccount, error) { var accounts []*authentication.UserAccount - return accounts, namedSelect(ctx, s.db, &accounts, sqlqueries.UserAccount.GetByIDs, args{ + return accounts, namedSelect(ctx, np, &accounts, sqlqueries.UserAccount.GetByIDs, args{ "user_ids": pq.Array(userIDs), }) } diff --git a/scripts/dev b/scripts/dev index 9afb8d3359..7ec499af23 100755 --- a/scripts/dev +++ b/scripts/dev @@ -122,7 +122,7 @@ def up(frontend_included, *args, debug:false, wait:false) "COMPOSE_HTTP_TIMEOUT" => "120", "AIR_CONFIG" =>conf, } - command = "docker compose #{"--profile frontend" if frontend_included } up --build" + command = "docker compose #{"--profile frontend" if frontend_included } up --build --remove-orphans" if args.any? command = "#{command} #{args.join(' ')}" @@ -360,6 +360,7 @@ namespace :db do business_cases governance_request_feedback system_intake_grb_reviewers + system_intake_internal_grb_review_discussion_posts system_intake_contacts system_intake_funding_sources system_intake_documents diff --git a/src/components/MentionTextArea/MentionList.tsx b/src/components/MentionTextArea/MentionList.tsx new file mode 100644 index 0000000000..4fde7ad184 --- /dev/null +++ b/src/components/MentionTextArea/MentionList.tsx @@ -0,0 +1,123 @@ +/* MentionList renders the TipTap suggestion dropdown in addition to defining +defining keyboard events */ + +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useState +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { TagType } from 'gql/gen/graphql'; + +import Spinner from 'components/Spinner'; +import { + MentionListOnKeyDown, + MentionSuggestionProps +} from 'types/discussions'; + +import './index.scss'; + +export const SuggestionLoading = () => { + return ( ++ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. +
+Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
', + votingRole: SystemIntakeGRBReviewerVotingRole.VOTING, + grbRole: SystemIntakeGRBReviewerRole.SUBJECT_MATTER_EXPERT, + createdByUserAccount: { + __typename: 'UserAccount', + id: '034fa2b3-00ff-4ec6-857e-75291a59df74', + commonName: users[5].commonName + }, + systemIntakeID, + createdAt: '2024-11-12T10:00:00.368862Z' + }, + replies: [ + { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '4099dbb7-2752-4bf9-a3e1-da225ceb9fae', + content: + 'Nisi nobis consectetur voluptatem neque tempore. Ea nihil sed beatae?
', + votingRole: SystemIntakeGRBReviewerVotingRole.NON_VOTING, + grbRole: SystemIntakeGRBReviewerRole.CMCS_REP, + createdByUserAccount: { + __typename: 'UserAccount', + id: '909a7888-4d6f-4bbf-9b9d-71ec1e2c3068', + commonName: users[1].commonName + }, + systemIntakeID, + createdAt: '2024-11-13T10:00:00.368862Z' + }, + { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '47b0081d-de33-4514-b68f-a7e2bbf2610f', + content: + 'Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur?
', + votingRole: SystemIntakeGRBReviewerVotingRole.VOTING, + grbRole: SystemIntakeGRBReviewerRole.CO_CHAIR_CIO, + createdByUserAccount: { + __typename: 'UserAccount', + id: '601d52be-7baa-4b45-91cd-88b4a5935c3f', + commonName: users[7].commonName + }, + systemIntakeID, + createdAt: '2024-11-13T10:00:00.368862Z' + } + ] + } +]; + +export const mockDiscussionsWithoutReplies = ( + systemIntakeID: string = systemIntake.id +): SystemIntakeGRBReviewDiscussionFragment[] => [ + { + __typename: 'SystemIntakeGRBReviewDiscussion', + initialPost: { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: 'd6cd88e2-d330-4b7c-9006-d6cf95b775e9', + content: 'This is a discussion without replies.
', + votingRole: SystemIntakeGRBReviewerVotingRole.NON_VOTING, + grbRole: SystemIntakeGRBReviewerRole.CCIIO_REP, + createdByUserAccount: { + __typename: 'UserAccount', + id: '3f750a9d-a2a2-414f-a013-59554ed32c75', + commonName: users[2].commonName + }, + systemIntakeID, + createdAt: '2024-11-18T10:00:00.368862Z' + }, + replies: [] + }, + { + __typename: 'SystemIntakeGRBReviewDiscussion', + initialPost: { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '372bf1c0-3f33-4046-a973-bc063f39dc59', + content: 'This is another discussion without replies.
', + votingRole: SystemIntakeGRBReviewerVotingRole.VOTING, + grbRole: SystemIntakeGRBReviewerRole.CO_CHAIR_HCA, + createdByUserAccount: { + __typename: 'UserAccount', + id: '32c29aac-e20c-4fce-9ecb-8eb2f4c87e5f', + commonName: users[9].commonName + }, + systemIntakeID, + createdAt: '2024-11-17T10:00:00.368862Z' + }, + replies: [] + }, + { + __typename: 'SystemIntakeGRBReviewDiscussion', + initialPost: { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '39823b79-987d-4f81-9fcb-ae45f4c5bfeb', + content: 'This is a third discussion without replies.
', + votingRole: SystemIntakeGRBReviewerVotingRole.VOTING, + grbRole: SystemIntakeGRBReviewerRole.QIO_REP, + createdByUserAccount: { + __typename: 'UserAccount', + id: 'a9628365-16a5-49bf-9acc-7dbdade7288f', + commonName: users[3].commonName + }, + systemIntakeID, + createdAt: '2024-11-17T9:00:00.368862Z' + }, + replies: [] + }, + { + __typename: 'SystemIntakeGRBReviewDiscussion', + initialPost: { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: 'df00daf2-f666-4014-98d3-fd28630fe996', + content: + 'This discussion without replies should not show up until list is expanded.
', + votingRole: SystemIntakeGRBReviewerVotingRole.ALTERNATE, + grbRole: SystemIntakeGRBReviewerRole.FED_ADMIN_BDG_CHAIR, + createdByUserAccount: { + __typename: 'UserAccount', + id: 'c7eefa37-b917-4fa3-8fc0-b86fa5de7df2', + commonName: users[8].commonName + }, + systemIntakeID, + createdAt: '2024-11-17T9:30:00.368862Z' + }, + replies: [] + } +]; diff --git a/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionPost.ts b/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionPost.ts new file mode 100644 index 0000000000..b0b2ea5457 --- /dev/null +++ b/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionPost.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +import SystemIntakeGRBReviewDiscussionPost from './SystemIntakeGRBReviewDiscussion'; + +export default gql(/* GraphQL */ ` + ${SystemIntakeGRBReviewDiscussionPost} + mutation CreateSystemIntakeGRBDiscussionPost( + $input: createSystemIntakeGRBDiscussionPostInput! + ) { + createSystemIntakeGRBDiscussionPost(input: $input) { + ...SystemIntakeGRBReviewDiscussionPost + } + } +`); diff --git a/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionReply.ts b/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionReply.ts new file mode 100644 index 0000000000..45d2c2c0a3 --- /dev/null +++ b/src/gql/apolloGQL/grbReview/CreateSystemIntakeGRBDiscussionReply.ts @@ -0,0 +1,14 @@ +import { gql } from '@apollo/client'; + +import SystemIntakeGRBReviewDiscussionPost from './SystemIntakeGRBReviewDiscussion'; + +export default gql(/* GraphQL */ ` + ${SystemIntakeGRBReviewDiscussionPost} + mutation CreateSystemIntakeGRBDiscussionReply( + $input: createSystemIntakeGRBDiscussionReplyInput! + ) { + createSystemIntakeGRBDiscussionReply(input: $input) { + ...SystemIntakeGRBReviewDiscussionPost + } + } +`); diff --git a/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBDiscussions.ts b/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBDiscussions.ts new file mode 100644 index 0000000000..f446a2cfe3 --- /dev/null +++ b/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBDiscussions.ts @@ -0,0 +1,15 @@ +import { gql } from '@apollo/client'; + +import SystemIntakeGRBReviewDiscussion from './SystemIntakeGRBReviewDiscussion'; + +export default gql(/* GraphQL */ ` + ${SystemIntakeGRBReviewDiscussion} + query GetSystemIntakeGRBDiscussions($id: UUID!) { + systemIntake(id: $id) { + id + grbDiscussions { + ...SystemIntakeGRBReviewDiscussion + } + } + } +`); diff --git a/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts b/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts index b27b4c3ac0..9f0a9461f8 100644 --- a/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts +++ b/src/gql/apolloGQL/grbReview/GetSystemIntakeGRBReviewers.ts @@ -2,7 +2,7 @@ import { gql } from '@apollo/client'; import SystemIntakeGRBReviewer from './SystemIntakeGRBReviewer'; -const GetSystemIntakeGRBReviewers = gql(/* GraphQL */ ` +export default gql(/* GraphQL */ ` ${SystemIntakeGRBReviewer} query GetSystemIntakeGRBReviewers($id: UUID!) { systemIntake(id: $id) { @@ -14,5 +14,3 @@ const GetSystemIntakeGRBReviewers = gql(/* GraphQL */ ` } } `); - -export default GetSystemIntakeGRBReviewers; diff --git a/src/gql/apolloGQL/grbReview/SystemIntakeGRBReviewDiscussion.ts b/src/gql/apolloGQL/grbReview/SystemIntakeGRBReviewDiscussion.ts new file mode 100644 index 0000000000..237f2bb2d0 --- /dev/null +++ b/src/gql/apolloGQL/grbReview/SystemIntakeGRBReviewDiscussion.ts @@ -0,0 +1,28 @@ +import { gql } from '@apollo/client'; + +export const SystemIntakeGRBReviewDiscussionPost = gql(/* GraphQL */ ` + fragment SystemIntakeGRBReviewDiscussionPost on SystemIntakeGRBReviewDiscussionPost { + id + content + votingRole + grbRole + createdByUserAccount { + id + commonName + } + systemIntakeID + createdAt + } +`); + +export default gql(/* GraphQL */ ` + ${SystemIntakeGRBReviewDiscussionPost} + fragment SystemIntakeGRBReviewDiscussion on SystemIntakeGRBReviewDiscussion { + initialPost { + ...SystemIntakeGRBReviewDiscussionPost + } + replies { + ...SystemIntakeGRBReviewDiscussionPost + } + } +`); diff --git a/src/gql/gen/graphql.ts b/src/gql/gen/graphql.ts index 20b6d9e795..da22b128bb 100644 --- a/src/gql/gen/graphql.ts +++ b/src/gql/gen/graphql.ts @@ -20,6 +20,8 @@ export type Scalars = { EmailAddress: { input: EmailAddress; output: EmailAddress; } /** HTML are represented using as strings,Notification email
*/ HTML: { input: HTML; output: HTML; } + /** TaggedHTML is represented using strings but can contain Tags (ex: @User) and possibly other richer elements than HTML */ + TaggedHTML: { input: any; output: any; } /** Time values are represented as strings using RFC3339 format, for example 2019-10-12T07:20:50.52Z */ Time: { input: Time; output: Time; } /** UUIDs are represented using 36 ASCII characters, for example B0511859-ADE6-4A67-8969-16EC280C0E1A */ @@ -965,6 +967,8 @@ export type Mutation = { createSystemIntakeActionUpdateLCID?: MaybeThis is a reply.
', + votingRole: SystemIntakeGRBReviewerVotingRole.NON_VOTING, + grbRole: SystemIntakeGRBReviewerRole.PROGRAM_OPERATIONS_BDG_CHAIR, + createdByUserAccount: { + __typename: 'UserAccount', + id: '859cd654-9fff-48d8-a9aa-a8a6491922b1', + commonName: users[4].commonName + }, + systemIntakeID, + createdAt: '2024-11-13T10:00:00.368862Z' + }, + { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '6d194b86-13fc-4d21-87e1-70dfb1514cf7', + content: 'This is a reply.
', + votingRole: SystemIntakeGRBReviewerVotingRole.VOTING, + grbRole: SystemIntakeGRBReviewerRole.CO_CHAIR_CIO, + createdByUserAccount: { + __typename: 'UserAccount', + id: '25d5e6cd-0cb9-4db8-aa9a-09458400ee7f', + commonName: users[8].commonName + }, + systemIntakeID, + createdAt: '2024-11-13T10:00:00.368862Z' + }, + { + __typename: 'SystemIntakeGRBReviewDiscussionPost', + id: '46e23f3f-37f5-4f50-87da-cf849df181d7', + content: 'This is a reply.
', + votingRole: SystemIntakeGRBReviewerVotingRole.ALTERNATE, + grbRole: SystemIntakeGRBReviewerRole.CCIIO_REP, + createdByUserAccount: { + __typename: 'UserAccount', + id: 'b5630d2b-89a0-4e7e-afd8-e2aa65f23823', + commonName: users[1].commonName + }, + systemIntakeID, + createdAt: '2024-11-13T10:00:00.368862Z' + } + ] + }; + + render( ++ {t('general.startDiscussion.description')} +
+ ++ {t('governanceReviewBoard.internal.description')} +
+{userAccount.commonName}
+ ++ {upperFirst(getRelativeDate(createdAt))} +
++ {lastReplyAtText} +
+ )} ++ {t('governanceReviewBoard.discussionsDescription')} +
+ +
+
+ {discussionsWithoutRepliesCount > 0 && (
+