Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions internal/ent/hooks/trustcenterndarequest.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@
"time"

"entgo.io/ent"
"github.com/samber/lo"
"github.com/theopenlane/iam/auth"
"github.com/theopenlane/iam/fgax"

"github.com/theopenlane/core/common/enums"
"github.com/theopenlane/core/common/models"
"github.com/theopenlane/core/internal/ent/generated"
"github.com/theopenlane/core/internal/ent/generated/hook"
"github.com/theopenlane/core/internal/ent/generated/organization"
"github.com/theopenlane/core/internal/ent/generated/orgmembership"
"github.com/theopenlane/core/internal/ent/generated/privacy"
"github.com/theopenlane/core/internal/ent/generated/template"
"github.com/theopenlane/core/internal/ent/generated/trustcenter"
Expand Down Expand Up @@ -93,6 +96,11 @@
if err := createNDARequestNotification(ctx, request, tc.OwnerID); err != nil {
logx.FromContext(ctx).Error().Err(err).Msg("failed to create NDA request notification")
}

if err := sendNDAApprovalRequestEmails(ctx, m.Client(), request, tc.OwnerID); err != nil {
logx.FromContext(ctx).Error().Err(err).Msg("failed to send NDA approval request emails")

Check failure on line 101 in internal/ent/hooks/trustcenterndarequest.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "failed to send NDA approval request emails" 3 times.

See more on https://sonarcloud.io/project/issues?id=theopenlane_core&issues=AZ6Slt7fNq_NGq_VPrDM&open=AZ6Slt7fNq_NGq_VPrDM&pullRequest=2453
}

return v, nil
}

Expand All @@ -109,7 +117,7 @@
}, ent.OpCreate)
}

func handleExistingNDARequest(ctx, queryCtx context.Context, client *generated.Client, existing *generated.TrustCenterNDARequest) (*generated.TrustCenterNDARequest, error) {

Check failure on line 120 in internal/ent/hooks/trustcenterndarequest.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=theopenlane_core&issues=AZ6Slt7fNq_NGq_VPrDN&open=AZ6Slt7fNq_NGq_VPrDN&pullRequest=2453
switch existing.Status {
case enums.TrustCenterNDARequestStatusSigned:
if err := sendSystemEmail(ctx, client, emaildef.TCAuthOp.Name(), emaildef.TrustCenterAuthEmail{
Expand Down Expand Up @@ -142,6 +150,10 @@
logx.FromContext(ctx).Error().Err(err).Msg("failed to create NDA request notification")
}

if err := sendNDAApprovalRequestEmails(ctx, client, existing, tc.OwnerID); err != nil {
logx.FromContext(ctx).Error().Err(err).Msg("failed to send NDA approval request emails")
}

return existing, nil
case enums.TrustCenterNDARequestStatusDeclined:
// if previously declined, set to needs approval again to restart the process
Expand All @@ -160,6 +172,10 @@
if err := createNDARequestNotification(ctx, existing, tc.OwnerID); err != nil {
logx.FromContext(ctx).Error().Err(err).Msg("failed to create NDA request notification")
}

if err := sendNDAApprovalRequestEmails(ctx, client, existing, tc.OwnerID); err != nil {
logx.FromContext(ctx).Error().Err(err).Msg("failed to send NDA approval request emails")
}
}

// otherwise do nothing invalid
Expand Down Expand Up @@ -317,3 +333,50 @@

return err
}

// ndaApproverRoles are the organization roles permitted to review and approve trust center NDA requests
var ndaApproverRoles = []enums.Role{enums.RoleOwner, enums.RoleSuperAdmin, enums.RoleAdmin}

// sendNDAApprovalRequestEmails notifies the organization's owners and admins, in a single message,
// that a trust center NDA request is pending their approval
func sendNDAApprovalRequestEmails(ctx context.Context, client *generated.Client, ndaRequest *generated.TrustCenterNDARequest, ownerID string) error {
allowCtx := privacy.DecisionContext(ctx, privacy.Allow)

org, err := client.Organization.Query().
Where(organization.IDEQ(ownerID)).
Select(organization.FieldDisplayName).
Only(allowCtx)
if err != nil {
return err
}

approvers, err := client.OrgMembership.Query().
Where(
orgmembership.OrganizationID(ownerID),
orgmembership.RoleIn(ndaApproverRoles...),
).
WithUser().
All(allowCtx)
if err != nil {
return err
}

emails := lo.Map(approvers, func(approver *generated.OrgMembership, _ int) string {
return approver.Edges.User.Email
})
if len(emails) == 0 {
return nil
}

requesterName := fmt.Sprintf("%s %s", ndaRequest.FirstName, ndaRequest.LastName)
if requesterName == " " {
requesterName = ""
}

return sendSystemEmail(ctx, client, emaildef.TCNDAApprovalRequestOp.Name(), emaildef.TrustCenterNDAApprovalRequestEmail{
RecipientInfo: emaildef.RecipientInfo{Email: emails[0], Recipients: emails},
OrgName: org.DisplayName,
RequesterName: requesterName,
RequesterEmail: ndaRequest.Email,
})
}
15 changes: 9 additions & 6 deletions internal/graphapi/trustcenterndarequest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ func TestMutationCreateTrustCenterNDARequest(t *testing.T) {
reqCleanupOrg2 := []string{}
ndaEmail := "Trust Center NDA Request"
authEmail := "Access"
approvalEmail := "Pending Approval"
testCases := []struct {
name string
input testclient.CreateTrustCenterNDARequestInput
Expand Down Expand Up @@ -92,7 +93,7 @@ func TestMutationCreateTrustCenterNDARequest(t *testing.T) {
client: suite.client.api,
ctx: tcOrg2.owner.UserCtx,
expectedStatus: enums.TrustCenterNDARequestStatusNeedsApproval,
expectEmailSent: "", // no email because not approved yet
expectEmailSent: approvalEmail, // approvers notified the request is pending approval
setStatus: &enums.TrustCenterNDARequestStatusApproved,
expectedSecondaryEmail: ndaEmail, // should get nda email
},
Expand Down Expand Up @@ -120,7 +121,7 @@ func TestMutationCreateTrustCenterNDARequest(t *testing.T) {
client: suite.client.api,
ctx: tcOrg2.owner.UserCtx,
expectedStatus: enums.TrustCenterNDARequestStatusNeedsApproval,
expectEmailSent: "", // no email because not approved yet
expectEmailSent: approvalEmail, // approvers notified the request is pending approval
setStatus: &enums.TrustCenterNDARequestStatusDeclined,
expectedSecondaryEmail: "",
},
Expand Down Expand Up @@ -877,6 +878,7 @@ func TestMutationRequestNewTrustCenterToken(t *testing.T) {

ndaEmail := "Trust Center NDA Request"
authEmail := "Access"
approvalEmail := "Pending Approval"
testCases := []struct {
name string
email string
Expand All @@ -900,10 +902,11 @@ func TestMutationRequestNewTrustCenterToken(t *testing.T) {
expectEmailSent: ndaEmail,
},
{
name: "needs approval, but no email sent still because not approved yet",
email: ndaNeedsApproval.Email,
client: suite.client.api,
ctx: anonCtxNeedsApproval,
name: "needs approval, approver notification email sent, requester not emailed because not approved yet",
email: ndaNeedsApproval.Email,
client: suite.client.api,
ctx: anonCtxNeedsApproval,
expectEmailSent: approvalEmail,
},
{
name: "no nda request, no-op",
Expand Down
11 changes: 9 additions & 2 deletions internal/integrations/definitions/email/emailop.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,10 @@ type Dispatcher interface {

// RecipientInfo holds recipient addressing fields embedded in every email operation input
type RecipientInfo struct {
// Email is the recipient email address
// Email is the recipient email address; it is the primary recipient used for personalization and footer links
Email string `json:"email" jsonschema:"required,description=Recipient email address"`
// Recipients optionally addresses the message to multiple recipients in a single send; when set it replaces Email as the To list
Recipients []string `json:"recipients,omitempty" jsonschema:"description=Recipient email addresses for a single multi-recipient message; replaces the single recipient when set"`
// FirstName is the recipient first name
FirstName string `json:"firstName,omitempty" jsonschema:"description=Recipient first name"`
// LastName is the recipient last name
Expand Down Expand Up @@ -238,9 +240,14 @@ func renderMessage(client *Client, theme *render.Theme, recipient RecipientInfo,
return nil, fmt.Errorf("%w: %w", ErrTemplateRenderFailed, err)
}

to := recipient.Recipients
if len(to) == 0 {
to = []string{recipient.Email}
}

opts := []newman.MessageOption{
newman.WithFrom(client.Config.FromEmail),
newman.WithTo([]string{recipient.Email}),
newman.WithTo(to),
newman.WithSubject(subject),
newman.WithHTML(htmlBody),
newman.WithText(textBody),
Expand Down
84 changes: 70 additions & 14 deletions internal/integrations/definitions/email/system_emails.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ const (
tcButtonTextColor = "#ffffff"
)

// ndaApprovalRequestPath is the console path where an approver reviews pending NDA requests
const ndaApprovalRequestPath = "/trust-center/NDAs"

// Brand palette colors sourced from the Openlane web design system (global.css)
const (
brandDarkGreen = "#0f3d3a" // lightened from --color-brand-950 (#092a2a)
Expand Down Expand Up @@ -221,23 +224,36 @@ type OrgDeletionNoticeEmail struct {
DeletionDate time.Time `json:"deletion_date" jsonschema:"required,description=Scheduled deletion date"`
}

// TrustCenterNDAApprovalRequestEmail is the input for notifying the organization's
// designated approver that an NDA request is pending approval.
type TrustCenterNDAApprovalRequestEmail struct {
RecipientInfo
// OrgName is the organization whose trust center received the NDA request
OrgName string `json:"org_name" jsonschema:"required,description=Organization name"`
// RequesterName is the name of the person requesting access
RequesterName string `json:"requester_name,omitempty" jsonschema:"description=Name of the requester"`
// RequesterEmail is the email address of the person requesting access
RequesterEmail string `json:"requester_email" jsonschema:"required,description=Email address of the requester"`
}

// --- Schema + operation ref vars ---

var (
verifyEmailSchema, VerifyEmailOp = providerkit.OperationSchema[VerifyEmailRequest]() //nolint:revive
welcomeSchema, WelcomeOp = providerkit.OperationSchema[WelcomeRequest]() //nolint:revive
inviteSchema, InviteOp = providerkit.OperationSchema[InviteRequest]() //nolint:revive
inviteJoinedSchema, InviteJoinedOp = providerkit.OperationSchema[InviteJoinedRequest]() //nolint:revive
resetRequestSchema, ResetRequestOp = providerkit.OperationSchema[PasswordResetEmailRequest]() //nolint:revive
resetSuccessSchema, ResetSuccessOp = providerkit.OperationSchema[PasswordResetSuccessRequest]() //nolint:revive
subscribeSchema, SubscribeOp = providerkit.OperationSchema[SubscribeRequest]() //nolint:revive
verifyBillingSchema, VerifyBillingOp = providerkit.OperationSchema[VerifyBillingRequest]() //nolint:revive
tcNDARequestSchema, TCNDARequestOp = providerkit.OperationSchema[TrustCenterNDARequestEmail]() //nolint:revive
tcNDASignedSchema, TCNDASignedOp = providerkit.OperationSchema[TrustCenterNDASignedEmail]() //nolint:revive
tcAuthSchema, TCAuthOp = providerkit.OperationSchema[TrustCenterAuthEmail]() //nolint:revive
questionnaireAuthSchema, QuestionnaireAuthOp = providerkit.OperationSchema[QuestionnaireAuthEmail]() //nolint:revive
billingEmailChangedSchema, BillingEmailChangedOp = providerkit.OperationSchema[BillingEmailChangedEmail]() //nolint:revive
orgDeletionNoticeSchema, OrgDeletionNoticeOp = providerkit.OperationSchema[OrgDeletionNoticeEmail]() //nolint:revive
verifyEmailSchema, VerifyEmailOp = providerkit.OperationSchema[VerifyEmailRequest]() //nolint:revive
welcomeSchema, WelcomeOp = providerkit.OperationSchema[WelcomeRequest]() //nolint:revive
inviteSchema, InviteOp = providerkit.OperationSchema[InviteRequest]() //nolint:revive
inviteJoinedSchema, InviteJoinedOp = providerkit.OperationSchema[InviteJoinedRequest]() //nolint:revive
resetRequestSchema, ResetRequestOp = providerkit.OperationSchema[PasswordResetEmailRequest]() //nolint:revive
resetSuccessSchema, ResetSuccessOp = providerkit.OperationSchema[PasswordResetSuccessRequest]() //nolint:revive
subscribeSchema, SubscribeOp = providerkit.OperationSchema[SubscribeRequest]() //nolint:revive
verifyBillingSchema, VerifyBillingOp = providerkit.OperationSchema[VerifyBillingRequest]() //nolint:revive
tcNDARequestSchema, TCNDARequestOp = providerkit.OperationSchema[TrustCenterNDARequestEmail]() //nolint:revive
tcNDASignedSchema, TCNDASignedOp = providerkit.OperationSchema[TrustCenterNDASignedEmail]() //nolint:revive
tcAuthSchema, TCAuthOp = providerkit.OperationSchema[TrustCenterAuthEmail]() //nolint:revive
questionnaireAuthSchema, QuestionnaireAuthOp = providerkit.OperationSchema[QuestionnaireAuthEmail]() //nolint:revive
billingEmailChangedSchema, BillingEmailChangedOp = providerkit.OperationSchema[BillingEmailChangedEmail]() //nolint:revive
orgDeletionNoticeSchema, OrgDeletionNoticeOp = providerkit.OperationSchema[OrgDeletionNoticeEmail]() //nolint:revive
tcNDAApprovalRequestSchema, TCNDAApprovalRequestOp = providerkit.OperationSchema[TrustCenterNDAApprovalRequestEmail]() //nolint:revive
)

// --- Email operation definitions ---
Expand Down Expand Up @@ -623,6 +639,46 @@ var _ = RegisterEmailOperation(Operation[TrustCenterAuthEmail]{
},
})

var _ = RegisterEmailOperation(Operation[TrustCenterNDAApprovalRequestEmail]{
Op: TCNDAApprovalRequestOp, Schema: tcNDAApprovalRequestSchema, Theme: baseTheme,
Description: "System email notifying a designated approver that a trust center NDA request is pending approval",
Subject: func(cfg RuntimeEmailConfig, _ TrustCenterNDAApprovalRequestEmail) string {
return "Trust Center NDA Request Pending Approval in " + cfg.CompanyName
},
Build: func(cfg RuntimeEmailConfig, req TrustCenterNDAApprovalRequestEmail) render.ContentBody {
requester := req.RequesterEmail
if req.RequesterName != "" {
requester = req.RequesterName + " (" + req.RequesterEmail + ")"
}

return render.ContentBody{
Preheader: "An NDA request is pending approval for " + req.OrgName + "'s Trust Center",
Header: defaultHeader(cfg),
Title: "NDA request pending approval",
Intros: render.IntrosBlock{
Paragraphs: []string{
"A new request to access protected Trust Center materials is pending your approval.",
"Please review the NDA request and approve or deny access as appropriate.",
},
},
Dictionary: render.Dictionary{
Cells: []render.Cell{
{Key: "Organization", Value: req.OrgName},
{Key: "Requester", Value: requester},
},
},
Actions: []render.Action{{
Button: render.Button{
Text: "Review NDA Request",
Link: cfg.ProductURL + ndaApprovalRequestPath,
Color: tcButtonColor,
TextColor: tcButtonTextColor,
},
}},
}
},
})

var questionnaireAuthEmail = RegisterEmailOperation(Operation[QuestionnaireAuthEmail]{
Op: QuestionnaireAuthOp, Schema: questionnaireAuthSchema, Theme: baseTheme,
Description: "System email delivering a time-limited authentication link to a questionnaire",
Expand Down
6 changes: 6 additions & 0 deletions internal/integrations/definitions/email/testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,12 @@ func TestFixture(name, toEmail string) json.RawMessage {
OrgName: "SecureCorp",
AuthURL: "https://trustcenter.example.com/securecorp/auth?token=test",
},
"TrustCenterNDAApprovalRequestEmail": TrustCenterNDAApprovalRequestEmail{
RecipientInfo: RecipientInfo{Email: toEmail, Recipients: []string{toEmail}, FirstName: r.FirstName, LastName: r.LastName},
OrgName: "SecureCorp",
RequesterName: "Dolores Abernathy",
RequesterEmail: "dolores.abernathy@example.com",
},
"QuestionnaireAuthEmail": QuestionnaireAuthEmail{
RecipientInfo: r,
OrgName: "Acme Corp",
Expand Down