From cd939f81e287611ea606afb567812939816053e3 Mon Sep 17 00:00:00 2001 From: Harold Wanyama Date: Tue, 28 Nov 2023 23:59:12 +0300 Subject: [PATCH] Feature/Docusign webhook - Consumed xml from the easycla webhooks upon signature signing - Send Email to users Signed-off-by: Harold Wanyama --- cla-backend-go/cmd/server.go | 2 +- cla-backend-go/emails/docusign_templates.go | 47 ++++ cla-backend-go/events/event_data.go | 18 ++ cla-backend-go/events/event_types.go | 2 + cla-backend-go/signatures/converters.go | 1 + cla-backend-go/signatures/dbmodels.go | 1 + cla-backend-go/signatures/handlers.go | 2 +- cla-backend-go/signatures/repository.go | 83 ++++++- cla-backend-go/signatures/service.go | 17 +- cla-backend-go/swagger/cla.v2.yaml | 86 ++++++- cla-backend-go/users/repository.go | 58 +++++ cla-backend-go/users/service.go | 17 ++ cla-backend-go/v2/sign/docusign.go | 58 +++++ cla-backend-go/v2/sign/handlers.go | 39 +++- cla-backend-go/v2/sign/helpers.go | 223 ++++++++++++++++++ cla-backend-go/v2/sign/models.go | 174 +++++++++++--- cla-backend-go/v2/sign/service.go | 237 ++++++++++++++++++-- cla-backend-go/v2/signatures/handlers.go | 2 +- cla-backend-go/v2/store/repository.go | 29 +++ cla-backend/cla/models/docusign_models.py | 2 +- 20 files changed, 1038 insertions(+), 60 deletions(-) create mode 100644 cla-backend-go/emails/docusign_templates.go create mode 100644 cla-backend-go/v2/sign/helpers.go diff --git a/cla-backend-go/cmd/server.go b/cla-backend-go/cmd/server.go index a8b814281..c84f15ab1 100644 --- a/cla-backend-go/cmd/server.go +++ b/cla-backend-go/cmd/server.go @@ -318,7 +318,7 @@ func server(localMode bool) http.Handler { v2GithubActivityService := v2GithubActivity.NewService(gitV1Repository, githubOrganizationsRepo, eventsService, autoEnableService, emailService) v2ClaGroupService := cla_groups.NewService(v1ProjectService, templateService, v1ProjectClaGroupRepo, v1ClaManagerService, v1SignaturesService, metricsRepo, gerritService, v1RepositoriesService, eventsService) - v2SignService := sign.NewService(configFile.ClaAPIV4Base, configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService) + v2SignService := sign.NewService(configFile.ClaAPIV4Base, configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService, configFile.CLALandingPage, configFile.CLALogoURL, emailService, eventsService) sessionStore, err := dynastore.New(dynastore.Path("/"), dynastore.HTTPOnly(), dynastore.TableName(configFile.SessionStoreTableName), dynastore.DynamoDB(dynamodb.New(awsSession))) if err != nil { diff --git a/cla-backend-go/emails/docusign_templates.go b/cla-backend-go/emails/docusign_templates.go new file mode 100644 index 000000000..b96781297 --- /dev/null +++ b/cla-backend-go/emails/docusign_templates.go @@ -0,0 +1,47 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package emails + +type DocumentSignedTemplateParams struct { + CommonEmailParams + CLAGroupTemplateParams + ICLA bool + PdfLink string +} + +const ( + // DocumentSignedTemplateName is email template name for DocumentSignedTemplate + DocumentSignedTemplateName = "DocumentSignedTemplate" + + // DocumentSignedTemplate is email template for + DocumentSignedICLATemplate = ` +

Hello {{.RecipientName}},

+

This is a notification email from EasyCLA regarding the project {{.Project.ExternalProjectName}}.

+

The CLA has now been signed. You can download the signed CLA as a PDF here.

+ ` + + DocumentSignedCCLATemplate = ` +

Hello {{.RecipientName}},

+

This is a notification email from EasyCLA regarding the project {{.Project.ExternalProjectName}}.

+

The CLA has now been signed. You can download the signed CLA as a PDF here, or from within the EasyCLA CLA Manager console .

+ ` +) + +// RenderDocumentSignedTemplate renders RenderDocumentSignedTemplate +func RenderDocumentSignedTemplate(svc EmailTemplateService, claGroupModelVersion, projectSFID string, params DocumentSignedTemplateParams) (string, error) { + claGroupParams, err := svc.GetCLAGroupTemplateParamsFromProjectSFID(claGroupModelVersion, projectSFID) + if err != nil { + return "", err + } + + params.CLAGroupTemplateParams = claGroupParams + var template string + if params.ICLA { + template = DocumentSignedICLATemplate + } else { + template = DocumentSignedCCLATemplate + } + + return RenderTemplate(claGroupModelVersion, DocumentSignedTemplateName, template, params) +} diff --git a/cla-backend-go/events/event_data.go b/cla-backend-go/events/event_data.go index cc8b0cc9d..074d49bda 100644 --- a/cla-backend-go/events/event_data.go +++ b/cla-backend-go/events/event_data.go @@ -445,6 +445,12 @@ type SignatureAutoCreateECLAUpdatedEventData struct { AutoCreateECLA bool } +type IndividualSignatureSignedEventData struct { + ProjectName string + Username string + ProjectID string +} + // GetEventDetailsString returns the details string for this event func (ed *SignatureAutoCreateECLAUpdatedEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) { @@ -2725,3 +2731,15 @@ func (ed *SignatureAutoCreateECLAUpdatedEventData) GetEventSummaryString(args *L data = data + "." return data, false } + +func (ed *IndividualSignatureSignedEventData) GetEventSummaryString(args *LogEventArgs) (string, bool) { + data := fmt.Sprintf("The user %s signed an individual CLA for project %s with project ID: %s", + args.LfUsername, ed.ProjectName, ed.ProjectID) + return data, false +} + +func (ed *IndividualSignatureSignedEventData) GetEventDetailsString(args *LogEventArgs) (string, bool) { + data := fmt.Sprintf("The user %s signed an individual CLA for project %s", + args.LfUsername, ed.ProjectName) + return data, false +} diff --git a/cla-backend-go/events/event_types.go b/cla-backend-go/events/event_types.go index 5e208b550..25962bbb9 100644 --- a/cla-backend-go/events/event_types.go +++ b/cla-backend-go/events/event_types.go @@ -96,4 +96,6 @@ const ( ProjectServiceCLAEnabled = "project.service.cla.enabled" ProjectServiceCLADisabled = "project.service.cla.disabled" SignatureAutoCreateECLAUpdated = "signature.auto_create_ecla.updated" + + IndividualSignatureSigned = "individual.signature.signed" ) diff --git a/cla-backend-go/signatures/converters.go b/cla-backend-go/signatures/converters.go index b1b91a6f1..c18fb7162 100644 --- a/cla-backend-go/signatures/converters.go +++ b/cla-backend-go/signatures/converters.go @@ -158,6 +158,7 @@ func (repo repository) buildProjectSignatureModels(ctx context.Context, results go func() { defer swg.Done() for _, userName := range sigACL { + log.WithFields(f).Debugf("looking up user by user name: %s", userName) if loadACLDetails { userModel, userErr := repo.usersRepo.GetUserByUserName(userName, true) if userErr != nil { diff --git a/cla-backend-go/signatures/dbmodels.go b/cla-backend-go/signatures/dbmodels.go index 695ad7407..9680b87da 100644 --- a/cla-backend-go/signatures/dbmodels.go +++ b/cla-backend-go/signatures/dbmodels.go @@ -44,6 +44,7 @@ type ItemSignature struct { UserDocusignName string `json:"user_docusign_name"` UserDocusignDateSigned string `json:"user_docusign_date_signed"` AutoCreateECLA bool `json:"auto_create_ecla"` + UserDocusignRawXML string `json:"user_docusign_raw_xml"` } // DBManagersModel is a database model for only the ACL/Manager column diff --git a/cla-backend-go/signatures/handlers.go b/cla-backend-go/signatures/handlers.go index 748f7eb76..5b4c02406 100644 --- a/cla-backend-go/signatures/handlers.go +++ b/cla-backend-go/signatures/handlers.go @@ -384,7 +384,7 @@ func Configure(api *operations.ClaAPI, service SignatureService, sessionStore *d api.SignaturesGetUserSignaturesHandler = signatures.GetUserSignaturesHandlerFunc(func(params signatures.GetUserSignaturesParams, claUser *user.CLAUser) middleware.Responder { reqID := utils.GetRequestID(params.XREQUESTID) ctx := context.WithValue(context.Background(), utils.XREQUESTID, reqID) // nolint - userSignatures, err := service.GetUserSignatures(ctx, params) + userSignatures, err := service.GetUserSignatures(ctx, params, nil) if err != nil { log.Warnf("error retrieving user signatures for userID: %s, error: %+v", params.UserID, err) return signatures.NewGetUserSignaturesBadRequest().WithXRequestID(reqID).WithPayload(errorResponse(err)) diff --git a/cla-backend-go/signatures/repository.go b/cla-backend-go/signatures/repository.go index 6aa528471..607c5bd49 100644 --- a/cla-backend-go/signatures/repository.go +++ b/cla-backend-go/signatures/repository.go @@ -72,6 +72,7 @@ type SignatureRepository interface { InvalidateProjectRecord(ctx context.Context, signatureID, note string) error UpdateEnvelopeDetails(ctx context.Context, signatureID, envelopeID string, signURL *string) (*models.Signature, error) CreateSignature(ctx context.Context, signature *ItemSignature) error + UpdateSignature(ctx context.Context, signatureID string, updates map[string]interface{}) error GetSignature(ctx context.Context, signatureID string) (*models.Signature, error) GetActivePullRequestMetadata(ctx context.Context, gitHubAuthorUsername, gitHubAuthorEmail string) (*ActivePullRequest, error) @@ -89,7 +90,7 @@ type SignatureRepository interface { CreateProjectCompanyEmployeeSignature(ctx context.Context, companyModel *models.Company, claGroupModel *models.ClaGroup, employeeUserModel *models.User) error GetCompanySignatures(ctx context.Context, params signatures.GetCompanySignaturesParams, pageSize int64, loadACL bool) (*models.Signatures, error) GetCompanyIDsWithSignedCorporateSignatures(ctx context.Context, claGroupID string) ([]SignatureCompanyID, error) - GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams, pageSize int64) (*models.Signatures, error) + GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams, pageSize int64, projectID *string) (*models.Signatures, error) ProjectSignatures(ctx context.Context, projectID string) (*models.Signatures, error) UpdateApprovalList(ctx context.Context, claManager *models.User, claGroupModel *models.ClaGroup, companyID string, params *models.ApprovalList, eventArgs *events.LogEventArgs) (*models.Signature, error) AddCLAManager(ctx context.Context, signatureID, claManagerID string) (*models.Signature, error) @@ -166,6 +167,73 @@ func (repo repository) CreateSignature(ctx context.Context, signature *ItemSigna } +// UpdateSignature updates an existing signature +func (repo repository) UpdateSignature(ctx context.Context, signatureID string, updates map[string]interface{}) error { + f := logrus.Fields{ + "functionName": "v1.signatures.repository.UpdateSignature", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "signatureID": signatureID, + } + + if len(updates) == 0 { + log.WithFields(f).Warnf("no updates provided") + return errors.New("no updates provided") + } + + var updateExpression strings.Builder + updateExpression.WriteString("SET ") + attributeValues := make(map[string]*dynamodb.AttributeValue) + expressionAttributeNames := make(map[string]*string) + + count := 1 + for attr, val := range updates { + attrPlaceholder := fmt.Sprintf("#A%d", count) + valPlaceholder := fmt.Sprintf(":v%d", count) + + if count > 1 && count <= len(updates) { + updateExpression.WriteString(", ") + } + updateExpression.WriteString(fmt.Sprintf("%s = %s", attrPlaceholder, valPlaceholder)) + + expressionAttributeNames[attrPlaceholder] = aws.String(attr) + av, err := dynamodbattribute.Marshal(val) + if err != nil { + return err + } + attributeValues[valPlaceholder] = av + + count++ + } + + log.WithFields(f).Debugf("updating signature using expression: %s", updateExpression.String()) + log.WithFields(f).Debugf("expression attribute names : %+v", expressionAttributeNames) + + input := &dynamodb.UpdateItemInput{ + ExpressionAttributeValues: attributeValues, + TableName: aws.String(repo.signatureTableName), + Key: map[string]*dynamodb.AttributeValue{ + "signature_id": { + S: aws.String(signatureID), + }, + }, + UpdateExpression: aws.String(updateExpression.String()), + ExpressionAttributeNames: expressionAttributeNames, + ReturnValues: aws.String("UPDATED_NEW"), + } + + // perform the update + _, err := repo.dynamoDBClient.UpdateItem(input) + if err != nil { + log.WithFields(f).Warnf("error updating signature, error: %v", err) + return err + } + + log.WithFields(f).Debugf("successfully updated signature") + + return nil + +} + // GetGithubOrganizationsFromApprovalList returns a list of GH organizations stored in the approval list func (repo repository) GetGithubOrganizationsFromApprovalList(ctx context.Context, signatureID string) ([]models.GithubOrg, error) { f := logrus.Fields{ @@ -2659,7 +2727,7 @@ func (repo repository) GetCompanyIDsWithSignedCorporateSignatures(ctx context.Co } // GetUserSignatures returns a list of user signatures for the specified user -func (repo repository) GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams, pageSize int64) (*models.Signatures, error) { +func (repo repository) GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams, pageSize int64, projectID *string) (*models.Signatures, error) { f := logrus.Fields{ "functionName": "v1.signatures.repository.GetUserSignatures", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), @@ -2667,8 +2735,15 @@ func (repo repository) GetUserSignatures(ctx context.Context, params signatures. // This is the keys we want to match condition := expression.Key("signature_reference_id").Equal(expression.Value(params.UserID)) + expressionBuilder := expression.NewBuilder().WithKeyCondition(condition).WithProjection(buildProjection()) + + if projectID != nil { + filterExpression := expression.Name("signature_project_id").Equal(expression.Value(*projectID)) + expressionBuilder = expressionBuilder.WithFilter(filterExpression) + } + // Use the nice builder to create the expression - expr, err := expression.NewBuilder().WithKeyCondition(condition).WithProjection(buildProjection()).Build() + expr, err := expressionBuilder.Build() if err != nil { log.WithFields(f).Warnf("error building expression for user signature query, userID: %s, error: %v", params.UserID, err) @@ -2715,6 +2790,8 @@ func (repo repository) GetUserSignatures(ctx context.Context, params signatures. return nil, errQuery } + log.WithFields(f).Debugf("query results count: %d", len(results.Items)) + // Convert the list of DB models to a list of response models signatureList, modelErr := repo.buildProjectSignatureModels(ctx, results, "", LoadACLDetails) if modelErr != nil { diff --git a/cla-backend-go/signatures/service.go b/cla-backend-go/signatures/service.go index 29cce20e2..f3ac6139e 100644 --- a/cla-backend-go/signatures/service.go +++ b/cla-backend-go/signatures/service.go @@ -52,9 +52,10 @@ type SignatureService interface { GetProjectCompanyEmployeeSignatures(ctx context.Context, params signatures.GetProjectCompanyEmployeeSignaturesParams, criteria *ApprovalCriteria) (*models.Signatures, error) GetCompanySignatures(ctx context.Context, params signatures.GetCompanySignaturesParams) (*models.Signatures, error) GetCompanyIDsWithSignedCorporateSignatures(ctx context.Context, claGroupID string) ([]SignatureCompanyID, error) - GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams) (*models.Signatures, error) + GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams, projectID *string) (*models.Signatures, error) InvalidateProjectRecords(ctx context.Context, projectID, note string) (int, error) CreateSignature(ctx context.Context, signature *ItemSignature) error + UpdateSignature(ctx context.Context, signatureID string, updates map[string]interface{}) error GetGithubOrganizationsFromApprovalList(ctx context.Context, signatureID string, githubAccessToken string) ([]models.GithubOrg, error) AddGithubOrganizationToApprovalList(ctx context.Context, signatureID string, approvalListParams models.GhOrgWhitelist, githubAccessToken string) ([]models.GithubOrg, error) @@ -72,6 +73,7 @@ type SignatureService interface { CreateOrUpdateEmployeeSignature(ctx context.Context, claGroupModel *models.ClaGroup, companyModel *models.Company, corporateSignatureModel *models.Signature) ([]*models.User, error) UpdateEnvelopeDetails(ctx context.Context, signatureID, envelopeID string, signURL *string) (*models.Signature, error) handleGitHubStatusUpdate(ctx context.Context, employeeUserModel *models.User) error + ProcessEmployeeSignature(ctx context.Context, companyModel *models.Company, claGroupModel *models.ClaGroup, user *models.User) (*bool, error) } type service struct { @@ -112,6 +114,11 @@ func (s service) GetSignature(ctx context.Context, signatureID string) (*models. return s.repo.GetSignature(ctx, signatureID) } +// UpdateSignature updates the specified signature +func (s service) UpdateSignature(ctx context.Context, signatureID string, updates map[string]interface{}) error { + return s.repo.UpdateSignature(ctx, signatureID, updates) +} + // GetIndividualSignature returns the signature associated with the specified CLA Group and User ID func (s service) GetIndividualSignature(ctx context.Context, claGroupID, userID string, approved, signed *bool) (*models.Signature, error) { return s.repo.GetIndividualSignature(ctx, claGroupID, userID, approved, signed) @@ -228,7 +235,7 @@ func (s service) GetCompanyIDsWithSignedCorporateSignatures(ctx context.Context, } // GetUserSignatures returns the list of user signatures associated with the specified user -func (s service) GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams) (*models.Signatures, error) { +func (s service) GetUserSignatures(ctx context.Context, params signatures.GetUserSignaturesParams, projectID *string) (*models.Signatures, error) { const defaultPageSize int64 = 10 var pageSize = defaultPageSize @@ -236,7 +243,7 @@ func (s service) GetUserSignatures(ctx context.Context, params signatures.GetUse pageSize = *params.PageSize } - userSignatures, err := s.repo.GetUserSignatures(ctx, params, pageSize) + userSignatures, err := s.repo.GetUserSignatures(ctx, params, pageSize, projectID) if err != nil { return nil, err } @@ -1160,7 +1167,7 @@ func (s service) hasUserSigned(ctx context.Context, user *models.User, projectID return &hasSigned, &companyAffiliation, claGroupModelErr } - employeeSigned, err := s.processEmployeeSignature(ctx, companyModel, claGroupModel, user) + employeeSigned, err := s.ProcessEmployeeSignature(ctx, companyModel, claGroupModel, user) if err != nil { log.WithFields(f).WithError(err).Warnf("problem looking up employee signature for company: %s", companyID) @@ -1177,7 +1184,7 @@ func (s service) hasUserSigned(ctx context.Context, user *models.User, projectID return &hasSigned, &companyAffiliation, nil } -func (s service) processEmployeeSignature(ctx context.Context, companyModel *models.Company, claGroupModel *models.ClaGroup, user *models.User) (*bool, error) { +func (s service) ProcessEmployeeSignature(ctx context.Context, companyModel *models.Company, claGroupModel *models.ClaGroup, user *models.User) (*bool, error) { f := logrus.Fields{ "functionName": "v2.signatures.service.processEmployeeSignature", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), diff --git a/cla-backend-go/swagger/cla.v2.yaml b/cla-backend-go/swagger/cla.v2.yaml index a15bbe795..88da007f5 100644 --- a/cla-backend-go/swagger/cla.v2.yaml +++ b/cla-backend-go/swagger/cla.v2.yaml @@ -4161,8 +4161,30 @@ paths: description: Receives XML data when an individual signs a document in DocuSign. security: [ ] operationId: iclaCallbackGithub + consumes: + - text/xml parameters: - $ref: "#/parameters/x-request-id" + - in: header + name: accept-encoding + type: string + required: false + default: gzip + - in: header + name: connection + type: string + required: false + default: Keep-Alive + - in: header + name: content-type + type: string + required: true + default: 'text/xml; charset=utf-8' + - in: header + name: user-agent + type: string + required: false + default: docusign - name: installation_id in: path required: true @@ -4175,17 +4197,19 @@ paths: in: path required: true type: string - - name: body + - name: envelopeInformation in: body required: true + description: XML payload with DocuSign envelope information schema: - type: object - additionalProperties: true + $ref: '#/definitions/DocuSignEnvelopeInformation' responses: '200': description: Successfully received and processed the callback data. '400': description: Invalid request. + '415': + description: Invalid format. tags: - sign @@ -4650,6 +4674,62 @@ definitions: event: $ref: './common/event.yaml' + #-------------------------------------- + # Docusign Webhook Payload + #____________________________________________ + DocuSignEnvelopeInformation: + type: object + properties: + EnvelopeStatus: + type: object + properties: + EnvelopeID: + type: string + Status: + type: string + RecipientStatuses: + type: array + items: + $ref: '#/definitions/RecipientStatus' + FormData: + type: object + properties: + xfdf: + type: object + properties: + fields: + type: array + items: + $ref: '#/definitions/Field' + xml: + name: DocuSignEnvelopeInformation + + RecipientStatus: + type: object + properties: + Type: + type: string + Email: + type: string + UserName: + type: string + Status: + type: string + ClientUserId: + type: string + xml: + name: RecipientStatus + + Field: + type: object + properties: + name: + type: string + value: + type: string + xml: + name: field + # --------------------------------------------------------------------------- # GitHub Definitions # --------------------------------------------------------------------------- diff --git a/cla-backend-go/users/repository.go b/cla-backend-go/users/repository.go index 3cb832ee5..2cb8c15d8 100644 --- a/cla-backend-go/users/repository.go +++ b/cla-backend-go/users/repository.go @@ -35,6 +35,7 @@ import ( type UserRepository interface { CreateUser(user *models.User) (*models.User, error) Save(user *models.UserUpdate) (*models.User, error) + UpdateUser(userID string, updates map[string]interface{}) (*models.User, error) Delete(userID string) error GetUser(userID string) (*models.User, error) GetUserByLFUserName(lfUserName string) (*models.User, error) @@ -213,6 +214,63 @@ func (repo repository) CreateUser(user *models.User) (*models.User, error) { return user, err } +func (repo repository) UpdateUser(userID string, updates map[string]interface{}) (*models.User, error) { + f := logrus.Fields{ + "functionName": "users.repository.UpdateUser", + "userID": userID, + } + + log.WithFields(f).Debugf("Updating user: %s with updates: %+v", userID, updates) + + if len(updates) == 0 { + return nil, errors.New(400, "no updates provided") + } + + var updateExpression strings.Builder + updateExpression.WriteString("SET ") + attributeValues := make(map[string]*dynamodb.AttributeValue) + attributeNames := make(map[string]*string) + + count := 1 + for key, value := range updates { + attrPlaceholder := fmt.Sprintf("#A%d", count) + valPlaceholder := fmt.Sprintf(":v%d", count) + + if count > 1 { + updateExpression.WriteString(", ") + } + updateExpression.WriteString(fmt.Sprintf("%s = %s", attrPlaceholder, valPlaceholder)) + attributeNames[attrPlaceholder] = aws.String(key) + + av, err := dynamodbattribute.Marshal(value) + if err != nil { + return nil, err + } + attributeValues[valPlaceholder] = av + + count++ + } + + input := &dynamodb.UpdateItemInput{ + ExpressionAttributeNames: attributeNames, + ExpressionAttributeValues: attributeValues, + Key: map[string]*dynamodb.AttributeValue{ + "user_id": { + S: aws.String(userID), + }, + }, + TableName: aws.String(repo.tableName), + UpdateExpression: aws.String(updateExpression.String()), + } + + _, err := repo.dynamoDBClient.UpdateItem(input) + if err != nil { + return nil, err + } + + return repo.GetUser(userID) +} + func (repo repository) getUserByUpdateModel(user *models.UserUpdate) (*models.User, error) { // Log fields f := logrus.Fields{ diff --git a/cla-backend-go/users/service.go b/cla-backend-go/users/service.go index 1a01605b5..c57e6897b 100644 --- a/cla-backend-go/users/service.go +++ b/cla-backend-go/users/service.go @@ -15,6 +15,7 @@ import ( type Service interface { CreateUser(user *models.User, claUser *user.CLAUser) (*models.User, error) Save(user *models.UserUpdate, claUser *user.CLAUser) (*models.User, error) + UpdateUser(userID string, updates map[string]interface{}) (*models.User, error) Delete(userID string, claUser *user.CLAUser) error GetUser(userID string) (*models.User, error) GetUserByLFUserName(lfUserName string) (*models.User, error) @@ -65,6 +66,22 @@ func (s service) CreateUser(user *models.User, claUser *user.CLAUser) (*models.U return userModel, nil } +func (s service) UpdateUser(userID string, updates map[string]interface{}) (*models.User, error) { + userModel, err := s.repo.UpdateUser(userID, updates) + if err != nil { + return nil, err + } + + // Log the event + s.events.LogEvent(&events.LogEventArgs{ + EventType: events.UserUpdated, + UserID: userID, + EventData: &events.UserUpdatedEventData{}, + }) + + return userModel, nil +} + // Save saves/updates the user record func (s service) Save(user *models.UserUpdate, claUser *user.CLAUser) (*models.User, error) { userModel, err := s.repo.Save(user) diff --git a/cla-backend-go/v2/sign/docusign.go b/cla-backend-go/v2/sign/docusign.go index d68be6ca5..080fd9f3d 100644 --- a/cla-backend-go/v2/sign/docusign.go +++ b/cla-backend-go/v2/sign/docusign.go @@ -565,3 +565,61 @@ func (s *service) GetSignURL(email, recipientID, userName, clientUserId, envelop return viewResponse.URL, nil } + +func (s service) getSignedDocument(ctx context.Context, envelopeID, documentID string) ([]byte, error) { + f := logrus.Fields{ + "functionName": "v2.getSignedDocument", + "envelopeID": envelopeID, + } + + // Get the access token + accessToken, err := s.getAccessToken(ctx) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("problem getting the access token") + return nil, err + } + + // Create the request + url := fmt.Sprintf("%s/accounts/%s/envelopes/%s/documents/%s", utils.GetProperty("DOCUSIGN_ROOT_URL"), utils.GetProperty("DOCUSIGN_ACCOUNT_ID"), envelopeID, documentID) + + req, err := http.NewRequest("GET", url, nil) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("problem creating the HTTP request") + return nil, err + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + + // Make the request + client := &http.Client{} + + resp, err := client.Do(req) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("problem making the HTTP request") + return nil, err + } + + defer func() { + if err = resp.Body.Close(); err != nil { + log.WithFields(f).WithError(err).Warnf("problem closing the response body") + } + }() + + responsePayload, err := io.ReadAll(resp.Body) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("problem reading the response body") + return nil, err + } + + if resp.StatusCode != http.StatusOK { + log.WithFields(f).Warnf("problem making the HTTP request - status code: %d - response : %s", resp.StatusCode, string(responsePayload)) + return nil, errors.New("problem making the HTTP request") + } + + return responsePayload, nil + +} diff --git a/cla-backend-go/v2/sign/handlers.go b/cla-backend-go/v2/sign/handlers.go index 762320bfe..dcf1968f1 100644 --- a/cla-backend-go/v2/sign/handlers.go +++ b/cla-backend-go/v2/sign/handlers.go @@ -4,10 +4,13 @@ package sign import ( + "bytes" "context" "encoding/json" "errors" "fmt" + "io" + "net/http" "strings" log "github.com/communitybridge/easycla/cla-backend-go/logging" @@ -24,6 +27,32 @@ import ( "github.com/go-openapi/runtime/middleware" ) +var ( + // payload is the payload for the docusign callback + iclaGitHubPayload []byte +) + +// docusignMiddleware is used to get access to xml request body +func docusignMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f := logrus.Fields{ + "functionName": "v2.sign.handlers.docusignMiddleware", + } + var err error + log.WithFields(f).Debug("docusign middleware...") + iclaGitHubPayload, err = io.ReadAll(r.Body) + if err != nil { + log.Warnf("unable to read request body") + return + } + r.Body.Close() + r.Body = io.NopCloser(bytes.NewBuffer(iclaGitHubPayload)) + log.WithFields(f).Debugf("docusign middleware...payload: %s", string(iclaGitHubPayload)) + // call the next middleware + next.ServeHTTP(w, r) + }) +} + // Configure API call func Configure(api *operations.EasyclaAPI, service Service, userService users.Service) { // Retrieve a list of available templates @@ -131,13 +160,8 @@ func Configure(api *operations.EasyclaAPI, service Service, userService users.Se "functionName": "v2.sign.handlers.SignIclaCallbackGithubHandler", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), } - jsonBytes, marshalErr := json.Marshal(params.Body) - if marshalErr != nil { - log.WithFields(f).WithError(marshalErr).Warn("unable to marshal github callback body") - return sign.NewIclaCallbackGithubBadRequest() - } - err := service.SignedIndividualCallbackGithub(ctx, jsonBytes, params.InstallationID, params.ChangeRequestID, params.GithubRepositoryID) + err := service.SignedIndividualCallbackGithub(ctx, iclaGitHubPayload, params.InstallationID, params.ChangeRequestID, params.GithubRepositoryID) if err != nil { return sign.NewIclaCallbackGithubBadRequest() } @@ -177,6 +201,7 @@ func Configure(api *operations.EasyclaAPI, service Service, userService users.Se "functionName": "v2.sign.handlers.SignIclaCallbackGerritHandler", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), } + log.WithFields(f).Debug("gerrit callback") payload, marshalErr := json.Marshal(params.Body) if marshalErr != nil { @@ -212,6 +237,8 @@ func Configure(api *operations.EasyclaAPI, service Service, userService users.Se } return sign.NewCclaCallbackOK() }) + + api.AddMiddlewareFor("POST", "/signed/individual/{installation_id}/{github_repository_id}/{change_request_id}", docusignMiddleware) } type codedResponse interface { diff --git a/cla-backend-go/v2/sign/helpers.go b/cla-backend-go/v2/sign/helpers.go new file mode 100644 index 000000000..c9520326e --- /dev/null +++ b/cla-backend-go/v2/sign/helpers.go @@ -0,0 +1,223 @@ +// Copyright The Linux Foundation and each contributor to CommunityBridge. +// SPDX-License-Identifier: MIT + +package sign + +import ( + "context" + "errors" + "fmt" + + "github.com/communitybridge/easycla/cla-backend-go/gen/v1/models" + "github.com/communitybridge/easycla/cla-backend-go/github" + log "github.com/communitybridge/easycla/cla-backend-go/logging" + "github.com/communitybridge/easycla/cla-backend-go/utils" + "github.com/sirupsen/logrus" +) + +// updateChangeRequest is a helper function that updates PR - typically after the docusign is completed +func (s service) updateChangeRequest(ctx context.Context, installationID, repositoryID, pullRequestID int64, projectID string) error { + f := logrus.Fields{ + "functionName": "v1.signatures.service.updateChangeRequest", + "repositoryID": repositoryID, + "pullRequestID": pullRequestID, + "projectID": projectID, + } + + githubRepository, ghErr := github.GetGitHubRepository(ctx, installationID, repositoryID) + if ghErr != nil { + log.WithFields(f).WithError(ghErr).Warn("unable to get github repository") + return ghErr + } + if githubRepository == nil || githubRepository.Owner == nil { + msg := "unable to get github repository - repository response is nil or owner is nil" + log.WithFields(f).Warn(msg) + return errors.New(msg) + } + // log.WithFields(f).Debugf("githubRepository: %+v", githubRepository) + if githubRepository.Name == nil || githubRepository.Owner.Login == nil { + msg := fmt.Sprintf("unable to get github repository - missing repository name or owner name for repository ID: %d", repositoryID) + log.WithFields(f).Warn(msg) + return errors.New(msg) + } + + gitHubOrgName := utils.StringValue(githubRepository.Owner.Login) + gitHubRepoName := utils.StringValue(githubRepository.Name) + + // Fetch committers + log.WithFields(f).Debugf("fetching commit authors for PR: %d using repository owner: %s, repo: %s", pullRequestID, gitHubOrgName, gitHubRepoName) + authors, latestSHA, authorsErr := github.GetPullRequestCommitAuthors(ctx, installationID, int(pullRequestID), gitHubOrgName, gitHubRepoName) + if authorsErr != nil { + log.WithFields(f).WithError(authorsErr).Warnf("unable to get commit authors for %s/%s for PR: %d", gitHubOrgName, gitHubRepoName, pullRequestID) + return authorsErr + } + log.WithFields(f).Debugf("found %d commit authors for %s/%s for PR: %d", len(authors), gitHubOrgName, gitHubRepoName, pullRequestID) + + signed := make([]*github.UserCommitSummary, 0) + unsigned := make([]*github.UserCommitSummary, 0) + + // triage signed and unsigned users + log.WithFields(f).Debugf("triaging %d commit authors for PR: %d using repository %s/%s", + len(authors), pullRequestID, gitHubOrgName, gitHubRepoName) + for _, userSummary := range authors { + + if !userSummary.IsValid() { + log.WithFields(f).Debugf("invalid user summary: %+v", *userSummary) + unsigned = append(unsigned, userSummary) + continue + } + + commitAuthorID := userSummary.GetCommitAuthorID() + commitAuthorUsername := userSummary.GetCommitAuthorUsername() + commitAuthorEmail := userSummary.GetCommitAuthorEmail() + + log.WithFields(f).Debugf("checking user - sha: %s, user ID: %s, username: %s, email: %s", + userSummary.SHA, commitAuthorID, commitAuthorUsername, commitAuthorEmail) + + var user *models.User + var userErr error + + if commitAuthorID != "" { + log.WithFields(f).Debugf("looking up user by ID: %s", commitAuthorID) + user, userErr = s.userService.GetUserByGitHubID(commitAuthorID) + if userErr != nil { + log.WithFields(f).WithError(userErr).Warnf("unable to get user by github id: %s", commitAuthorID) + } + if user != nil { + log.WithFields(f).Debugf("found user by ID: %s", commitAuthorID) + } + } + if user == nil && commitAuthorUsername != "" { + log.WithFields(f).Debugf("looking up user by username: %s", commitAuthorUsername) + user, userErr = s.userService.GetUserByGitHubUsername(commitAuthorUsername) + if userErr != nil { + log.WithFields(f).WithError(userErr).Warnf("unable to get user by github username: %s", commitAuthorUsername) + } + if user != nil { + log.WithFields(f).Debugf("found user by username: %s", commitAuthorUsername) + } + } + if user == nil && commitAuthorEmail != "" { + log.WithFields(f).Debugf("looking up user by email: %s", commitAuthorEmail) + user, userErr = s.userService.GetUserByEmail(commitAuthorEmail) + if userErr != nil { + log.WithFields(f).WithError(userErr).Warnf("unable to get user by user email: %s", commitAuthorEmail) + } + if user != nil { + log.WithFields(f).Debugf("found user by email: %s", commitAuthorEmail) + } + } + + if user == nil { + log.WithFields(f).Debugf("unable to find user for commit author - sha: %s, user ID: %s, username: %s, email: %s", + userSummary.SHA, commitAuthorID, commitAuthorUsername, commitAuthorEmail) + unsigned = append(unsigned, userSummary) + continue + } + + log.WithFields(f).Debugf("checking to see if user has signed an ICLA or ECLA for project: %s", projectID) + userSigned, companyAffiliation, signedErr := s.hasUserSigned(ctx, user, projectID) + if signedErr != nil { + log.WithFields(f).WithError(signedErr).Warnf("has user signed error - user: %+v, project: %s", user, projectID) + unsigned = append(unsigned, userSummary) + continue + } + + if companyAffiliation != nil { + userSummary.Affiliated = *companyAffiliation + } + + if userSigned != nil { + userSummary.Authorized = *userSigned + if userSummary.Authorized { + signed = append(signed, userSummary) + } else { + unsigned = append(unsigned, userSummary) + } + } + } + + log.WithFields(f).Debugf("commit authors status => signed: %+v and missing: %+v", signed, unsigned) + + // update pull request + updateErr := github.UpdatePullRequest(ctx, installationID, int(pullRequestID), gitHubOrgName, gitHubRepoName, githubRepository.ID, *latestSHA, signed, unsigned, s.ClaV1ApiURL, s.claLandingPage, s.claLogoURL) + if updateErr != nil { + log.WithFields(f).Debugf("unable to update PR: %d", pullRequestID) + return updateErr + } + + return nil +} + +// hasUserSigned checks to see if the user has signed an ICLA or ECLA for the project, returns: +// false, false, nil if user is not authorized for ICLA or ECLA +// false, false, some error if user is not authorized for ICLA or ECLA - we has some problem looking up stuff +// true, false, nil if user has an ICLA (authorized, but not company affiliation, no error) +// true, true, nil if user has an ECLA (authorized, with company affiliation, no error) +func (s service) hasUserSigned(ctx context.Context, user *models.User, projectID string) (*bool, *bool, error) { + f := logrus.Fields{ + "functionName": "v1.signatures.service.updateChangeRequest", + "projectID": projectID, + "user": user, + } + var hasSigned bool + var companyAffiliation bool + + approved := true + signed := true + + // Check for ICLA + log.WithFields(f).Debugf("checking to see if user has signed an ICLA") + signature, sigErr := s.signatureService.GetIndividualSignature(ctx, projectID, user.UserID, &approved, &signed) + if sigErr != nil { + log.WithFields(f).WithError(sigErr).Warnf("problem checking for ICLA signature for user: %s", user.UserID) + return &hasSigned, &companyAffiliation, sigErr + } + if signature != nil { + hasSigned = true + log.WithFields(f).Debugf("ICLA signature check passed for user: %+v on project : %s", user, projectID) + return &hasSigned, &companyAffiliation, nil // ICLA passes, no company affiliation + } else { + log.WithFields(f).Debugf("ICLA signature check failed for user: %+v on project: %s - ICLA not signed", user, projectID) + } + + // Check for Employee Acknowledgment ECLA + companyID := user.CompanyID + log.WithFields(f).Debugf("checking to see if user has signed a ECLA for company: %s", companyID) + + if companyID != "" { + companyAffiliation = true + + // Get employee signature + log.WithFields(f).Debugf("ECLA signature check - user has a company: %s - looking for user's employee acknowledgement...", companyID) + + // Load the company - make sure it is valid + companyModel, compModelErr := s.companyService.GetCompany(ctx, companyID) + if compModelErr != nil { + log.WithFields(f).WithError(compModelErr).Warnf("problem looking up company: %s", companyID) + return &hasSigned, &companyAffiliation, compModelErr + } + + // Load the CLA Group - make sure it is valid + claGroupModel, claGroupModelErr := s.claGroupService.GetCLAGroup(ctx, projectID) + if claGroupModelErr != nil { + log.WithFields(f).WithError(claGroupModelErr).Warnf("problem looking up project: %s", projectID) + return &hasSigned, &companyAffiliation, claGroupModelErr + } + + employeeSigned, err := s.signatureService.ProcessEmployeeSignature(ctx, companyModel, claGroupModel, user) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("problem looking up employee signature for company: %s", companyID) + return &hasSigned, &companyAffiliation, err + } + if employeeSigned != nil { + hasSigned = *employeeSigned + } + + } else { + log.WithFields(f).Debugf("ECLA signature check - user does not have a company ID assigned - skipping...") + } + + return &hasSigned, &companyAffiliation, nil +} diff --git a/cla-backend-go/v2/sign/models.go b/cla-backend-go/v2/sign/models.go index 9b053db76..9951f612d 100644 --- a/cla-backend-go/v2/sign/models.go +++ b/cla-backend-go/v2/sign/models.go @@ -6,7 +6,6 @@ package sign import ( "database/sql" "encoding/xml" - "time" ) // DocuSignGetTokenRequest is the request body for getting a token from DocuSign @@ -311,9 +310,9 @@ type IndividualMembershipDocuSignDBSummaryModel struct { DocuSignEnvelopeID string `db:"docusign_envelope_id"` DocuSignEnvelopeCreatedAt string `db:"docusign_envelope_created_at"` DocuSignEnvelopeSigningStatus string `db:"docusign_envelope_signing_status"` - DocuSignEnvelopeSigningUpdatedAt time.Time `db:"docusign_envelope_signing_updated_at"` + DocuSignEnvelopeSigningUpdatedAt string `db:"docusign_envelope_signing_updated_at"` Memo sql.NullString `db:"memo"` - //DocuSignEnvelopeSignedDate time.Time `json:"docusign_envelope_signed_date"` + //DocuSignEnvelopeSignedDate string `json:"docusign_envelope_signed_date"` } type ClaSignatoryEmailParams struct { @@ -334,6 +333,15 @@ type DocuSignEventNotification struct { URL string `json:"url"` LoggingEnabled bool `json:"loggingEnabled"` EnvelopeEvents []DocuSignRecipientEvent `json:"envelopeEvents"` + // EventData EventData `json:"eventData"` + // RequireAcknowledgment string `json:"requireAcknowledgment"` +} + +// EventData represents the eventData attribute in DocusignEventNotification. +type EventData struct { + Version string `json:"version,omitempty"` + Format string `json:"format,omitempty"` + IncludeData []string `json:"includeData,omitempty"` } type Recipient struct { @@ -407,7 +415,7 @@ type DocuSignWebhookModel struct { ConfigurationID int `json:"configurationId"` // 10418598 Data DocuSignWebhookData `json:"data"` Event string `json:"event"` // envelope-sent, envelope-completed - GeneratedDateTime time.Time `json:"generatedDateTime"` // generated_date_time + GeneratedDateTime string `json:"generatedDateTime"` // generated_date_time URI string `json:"uri"` // /restapi/v2.1/accounts/77c754e9-4016-4ccc-957f-15eaa18f2d22/envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a } @@ -428,7 +436,7 @@ type DocuSignEnvelopeSummary struct { AutoNavigation string `json:"autoNavigation"` // "true" BurnDefaultTabData string `json:"burnDefaultTabData"` // "false" CertificateURI string `json:"certificateUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/documents/summary - CreatedDateTime time.Time `json:"createdDateTime"` // 2023-05-26T18:55:47.18Z + CreatedDateTime string `json:"createdDateTime"` // 2023-05-26T18:55:47.18Z CustomFieldsURI string `json:"customFieldsUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/custom_fields DocumentsCombinedURI string `json:"documentsCombinedUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/documents/combined DocumentsURI string `json:"documentsUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/documents @@ -440,25 +448,25 @@ type DocuSignEnvelopeSummary struct { EnvelopeMetadata EnvelopeMetadata `json:"envelopeMetadata"` EnvelopeURI string `json:"envelopeUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a ExpiresAfter string `json:"expiresAfter"` // 120 - ExpireDateTime time.Time `json:"expireDateTime"` // 2023-05-26T18:55:48.257Z + ExpireDateTime string `json:"expireDateTime"` // 2023-05-26T18:55:48.257Z ExpireEnabled string `json:"expireEnabled"` // "true" HasComments string `json:"hasComments"` // "false" HasFormDataChanged string `json:"hasFormDataChanged"` // "false" - InitialSendDateTime time.Time `json:"initialSendDateTime"` // 2023-05-26T18:55:48.257Z + InitialSendDateTime string `json:"initialSendDateTime"` // 2023-05-26T18:55:48.257Z Is21CFRPart11 string `json:"is21CFRPart11"` // "false" IsDynamicEnvelope string `json:"isDynamicEnvelope"` // "false" IsSignatureProviderEnvelope string `json:"isSignatureProviderEnvelope"` // "false" - LastModifiedDateTime time.Time `json:"lastModifiedDateTime"` // 2023-05-26T18:55:48.257Z + LastModifiedDateTime string `json:"lastModifiedDateTime"` // 2023-05-26T18:55:48.257Z NotificationURI string `json:"notificationUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/notification PurgeState string `json:"purgeState"` // unpurged Recipients Recipients `json:"recipients"` RecipientsURI string `json:"recipientsUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/recipients Sender Sender `json:"sender"` - SentDateTime time.Time `json:"sentDateTime"` // 2023-05-26T18:55:48.257Z + SentDateTime string `json:"sentDateTime"` // 2023-05-26T18:55:48.257Z SignerCanSignOnMobile string `json:"signerCanSignOnMobile"` // "true" SignerLocation string `json:"signerLocation"` // online Status string `json:"status"` // sent - StatusChangedDateTime time.Time `json:"statusChangedDateTime"` // 2023-05-26T18:55:48.257Z + StatusChangedDateTime string `json:"statusChangedDateTime"` // 2023-05-26T18:55:48.257Z TemplatesURI string `json:"templatesUri"` // /envelopes/016d4678-bf5c-41f3-b7c9-5c58606cdb4a/templates0:w } @@ -484,21 +492,21 @@ type EnvelopeMetadata struct { } type WebhookSigner struct { - CompletedCount string `json:"completedCount"` // 0 - CreationReason string `json:"creationReason"` // sender - DeliveryMethod string `json:"deliveryMethod"` // email - Email string `json:"email"` // test@test - IsBulkRecipient string `json:"isBulkRecipient"` // "false" - Name string `json:"name"` // Test DocuSign - RecipientID string `json:"recipientId"` // 1 - RecipientIDGuid string `json:"recipientIdGuid"` // 9fd66d5d-7396-4b80-a85e-2a7e536471b1 - ReceipientType string `json:"recipientType"` // signer - RequireIdLookup string `json:"requireIdLookup"` // "false" - RequireUploadSignature string `json:"requireUploadSignature"` // "false" - RoutingOrder string `json:"routingOrder"` // 1 - SentDateTime time.Time `json:"sentDateTime"` // 2023-05-26T18:55:48.257Z - Status string `json:"status"` // sent - UserId string `json:"userId"` // 9fd66d5d-7396-4b80-a85e-2a7e536471b1 + CompletedCount string `json:"completedCount"` // 0 + CreationReason string `json:"creationReason"` // sender + DeliveryMethod string `json:"deliveryMethod"` // email + Email string `json:"email"` // test@test + IsBulkRecipient string `json:"isBulkRecipient"` // "false" + Name string `json:"name"` // Test DocuSign + RecipientID string `json:"recipientId"` // 1 + RecipientIDGuid string `json:"recipientIdGuid"` // 9fd66d5d-7396-4b80-a85e-2a7e536471b1 + ReceipientType string `json:"recipientType"` // signer + RequireIdLookup string `json:"requireIdLookup"` // "false" + RequireUploadSignature string `json:"requireUploadSignature"` // "false" + RoutingOrder string `json:"routingOrder"` // 1 + SentDateTime string `json:"sentDateTime"` // 2023-05-26T18:55:48.257Z + Status string `json:"status"` // sent + UserId string `json:"userId"` // 9fd66d5d-7396-4b80-a85e-2a7e536471b1 } type Sender struct { @@ -508,3 +516,119 @@ type Sender struct { UserName string `json:"userName"` // Test DocuSign UserID string `json:"userId"` // 9fd66d5d-7396-4b80-a85e-2a7e536471b1 } + +// DocuSignEnvelopeInformation is the root element +type DocuSignEnvelopeInformation struct { + XMLName xml.Name `xml:"DocuSignEnvelopeInformation"` + EnvelopeStatus EnvelopeStatus `xml:"EnvelopeStatus"` + // Additional fields can be added here if needed + FormData string `xml:"FormData"` +} + +// EnvelopeStatus represents the element +type EnvelopeStatus struct { + RecipientStatuses []RecipientStatus `xml:"RecipientStatuses>RecipientStatus"` + TimeGenerated string `xml:"TimeGenerated"` + EnvelopeID string `xml:"EnvelopeID"` + Subject string `xml:"Subject"` + UserName string `xml:"UserName"` + Email string `xml:"Email"` + Status string `xml:"Status"` + Created string `xml:"Created"` + Sent string `xml:"Sent"` + Delivered string `xml:"Delivered"` + Signed string `xml:"Signed"` + Completed string `xml:"Completed"` + ACStatus string `xml:"ACStatus"` + ACStatusDate string `xml:"ACStatusDate"` + ACHolder string `xml:"ACHolder"` + ACHolderEmail string `xml:"ACHolderEmail"` + ACHolderLocation string `xml:"ACHolderLocation"` + SigningLocation string `xml:"SigningLocation"` + SenderIPAddress string `xml:"SenderIPAddress"` + EnvelopePDFHash string `xml:"EnvelopePDFHash"` // Assuming string, adjust as necessary + CustomFields string `xml:"CustomFields"` // Assuming string, adjust as necessary + AutoNavigation bool `xml:"AutoNavigation"` + EnvelopeIdStamping bool `xml:"EnvelopeIdStamping"` + AuthoritativeCopy bool `xml:"AuthoritativeCopy"` + DocumentStatuses []DocumentStatus `xml:"DocumentStatuses>DocumentStatus"` + // Additional fields can be added here if needed +} + +// RecipientStatus represents the element +type RecipientStatus struct { + Type string `xml:"Type"` + Email string `xml:"Email"` + UserName string `xml:"UserName"` + RoutingOrder int `xml:"RoutingOrder"` + Sent string `xml:"Sent"` + Delivered string `xml:"Delivered"` + Signed string `xml:"Signed"` + DeclineReason string `xml:"DeclineReason"` + Status string `xml:"Status"` + RecipientIPAddress string `xml:"RecipientIPAddress"` + ClientUserId string `xml:"ClientUserId"` + CustomFields string `xml:"CustomFields"` + TabStatuses []TabStatus `xml:"TabStatuses>TabStatus"` + RecipientAttachment []Attachment `xml:"RecipientAttachment>Attachment"` + AccountStatus string `xml:"AccountStatus"` + EsignAgreementInformation EsignAgreement `xml:"EsignAgreementInformation"` + FormData FormData `xml:"FormData"` + RecipientId string `xml:"RecipientId"` + // Additional fields can be added here if needed +} + +// TabStatus represents the element +type TabStatus struct { + TabType string `xml:"TabType"` + Status string `xml:"Status"` + XPosition int `xml:"XPosition"` + YPosition int `xml:"YPosition"` + TabLabel string `xml:"TabLabel"` + TabName string `xml:"TabName"` + TabValue string `xml:"TabValue"` + DocumentID string `xml:"DocumentID"` + PageNumber int `xml:"PageNumber"` + CustomTabType string `xml:"CustomTabType"` + // Additional fields can be added here if needed +} + +// Attachment represents the element +type Attachment struct { + Data string `xml:"Data"` + Label string `xml:"Label"` + // Additional fields can be added here if needed +} + +// EsignAgreement represents the element +type EsignAgreement struct { + AccountEsignId string `xml:"AccountEsignId"` +} + +// FormData represents the element +type FormData struct { + XFDF XFDF `xml:"xfdf"` + // Additional fields can be added here if needed +} + +// XFDF represents the element within +type XFDF struct { + Fields []Field `xml:"fields>field"` + // Additional fields can be added here if needed +} + +// Field represents the element within +type Field struct { + Name string `xml:"name,attr"` + Value string `xml:"value"` + // Additional fields can be added here if needed +} + +// DocumentStatus represents the element +type DocumentStatus struct { + ID string `xml:"ID"` + Name string `xml:"Name"` + TemplateName string `xml:"TemplateName"` + Sequence int `xml:"Sequence"` + // Additional fields can be added here if needed +} diff --git a/cla-backend-go/v2/sign/service.go b/cla-backend-go/v2/sign/service.go index d32aa9f41..0526434d0 100644 --- a/cla-backend-go/v2/sign/service.go +++ b/cla-backend-go/v2/sign/service.go @@ -6,7 +6,7 @@ package sign import ( "context" "encoding/base64" - "encoding/json" + "encoding/xml" "errors" "fmt" "io" @@ -17,6 +17,8 @@ import ( "strings" "time" + "github.com/communitybridge/easycla/cla-backend-go/emails" + "github.com/communitybridge/easycla/cla-backend-go/events" "github.com/communitybridge/easycla/cla-backend-go/github" "github.com/communitybridge/easycla/cla-backend-go/github_organizations" "github.com/communitybridge/easycla/cla-backend-go/project/common" @@ -35,6 +37,7 @@ import ( acsService "github.com/communitybridge/easycla/cla-backend-go/v2/acs-service" "github.com/communitybridge/easycla/cla-backend-go/v2/organization-service/client/organizations" + sigs "github.com/communitybridge/easycla/cla-backend-go/gen/v1/restapi/operations/signatures" organizationService "github.com/communitybridge/easycla/cla-backend-go/v2/organization-service" projectService "github.com/communitybridge/easycla/cla-backend-go/v2/project-service" @@ -99,11 +102,15 @@ type service struct { repositoryService repositories.Service githubOrgService github_organizations.Service gitlabOrgService gitlab_organizations.ServiceInterface + claLandingPage string + claLogoURL string + emailTemplateService emails.EmailTemplateService + eventsService events.Service } // NewService returns an instance of v2 project service func NewService(apiURL, v1API string, compRepo company.IRepository, projectRepo ProjectRepo, pcgRepo projects_cla_groups.Repository, compService company.IService, claGroupService cla_groups.Service, docsignPrivateKey string, userService users.Service, signatureService signatures.SignatureService, storeRepository store.Repository, - repositoryService repositories.Service, githubOrgService github_organizations.Service, gitlabOrgService gitlab_organizations.ServiceInterface) Service { + repositoryService repositories.Service, githubOrgService github_organizations.Service, gitlabOrgService gitlab_organizations.ServiceInterface, claLandingPage string, claLogoURL string, emailTemplateService emails.EmailTemplateService, eventsService events.Service) Service { return &service{ ClaV4ApiURL: apiURL, ClaV1ApiURL: v1API, @@ -119,6 +126,9 @@ func NewService(apiURL, v1API string, compRepo company.IRepository, projectRepo githubOrgService: githubOrgService, gitlabOrgService: gitlabOrgService, repositoryService: repositoryService, + claLandingPage: claLandingPage, + claLogoURL: claLogoURL, + emailTemplateService: emailTemplateService, } } @@ -357,22 +367,212 @@ func (s *service) SignedIndividualCallbackGithub(ctx context.Context, payload [] log.WithFields(f).Debug("processing signed individual callback...") - var dataModel DocuSignWebhookModel + var info DocuSignEnvelopeInformation - // var dataModel Payload + err := xml.Unmarshal(payload, &info) + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to unmarshal xml payload") + return err + } + + envelopeID := info.EnvelopeStatus.EnvelopeID + signatureID := info.EnvelopeStatus.RecipientStatuses[0].ClientUserId + status := info.EnvelopeStatus.RecipientStatuses[0].Status + signedDate := info.EnvelopeStatus.RecipientStatuses[0].Signed + documentID := info.EnvelopeStatus.DocumentStatuses[0].ID + fullName := fetchFullName(info) + + log.WithFields(f).Debugf("envelopeID: %s, signatureID: %s, status: %s, signedDate: %s, fullName: %s", envelopeID, signatureID, status, signedDate, fullName) - err := json.Unmarshal(payload, &dataModel) + _, currentTime := utils.CurrentTime() + + signature, err := s.signatureService.GetSignature(ctx, signatureID) if err != nil { - log.WithFields(f).WithError(err).Warn("unable to unmarshall payload") + log.WithFields(f).WithError(err).Warn("unable to lookup signature by ID") return err } - log.WithFields(f).Debugf("webhook payload: %+v", dataModel) + if signature == nil { + log.WithFields(f).WithError(err).Warn("unable to lookup signature by ID - signature not found") + return errors.New("unable to lookup signature by ID - signature not found") + } + + if status == "Completed" { + log.WithFields(f).Debugf("envelope signed - status: %s", status) + updates := map[string]interface{}{ + "signature_signed": true, + "date_modified": currentTime, + "signed_on": currentTime, + "user_docusign_raw_xml": string(payload), + "user_docusign_name": fullName, + "user_docusign_date_signed": signedDate, + } + + err = s.signatureService.UpdateSignature(ctx, signatureID, updates) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to update signature record with envelope ID: %s", envelopeID) + return err + } + + log.WithFields(f).Debugf("updated signature record: %s", signatureID) + + // Update the repository provider with this change - this will update the comment (if necessary) + // and the status - do this early in the flow as the user will be immediately redirected back + installtionIDInt, err := strconv.Atoi(installationID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to convert installation ID to int: %s", installationID) + return err + } + + repositoryIDInt, err := strconv.Atoi(repositoryID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to convert repository ID to int: %s", repositoryID) + return err + } + + changeRequestIDInt, err := strconv.Atoi(changeRequestID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to convert change request ID to int: %s", changeRequestID) + return err + } + + log.WithFields(f).Debugf("updating change request for installation ID: %d, repository ID: %d, change request ID: %d", installtionIDInt, repositoryIDInt, changeRequestIDInt) + err = s.updateChangeRequest(ctx, int64(installtionIDInt), int64(repositoryIDInt), int64(changeRequestIDInt), signature.ProjectID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to update change request: %s", changeRequestID) + return err + } + + claUser, userErr := s.userService.GetUser(signature.SignatureReferenceID) + if userErr != nil { + log.WithFields(f).WithError(userErr).Warnf("unable to lookup user by ID: %s", signature.SignatureReferenceID) + return userErr + } + + if claUser.Username == "" { + if fullName != "" { + log.WithFields(f).Debugf("setting username for user with :%s", fullName) + updates = map[string]interface{}{ + "user_name": fullName, + } + log.WithFields(f).Debugf("updating user with username: %s", fullName) + _, err = s.userService.UpdateUser(signature.SignatureReferenceID, updates) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to update user with username: %s", fullName) + return err + } + } + } + + // Remove the active signature + log.WithFields(f).Debugf("removing active signature metadata for user: %s", signature.SignatureReferenceID) + key := fmt.Sprintf("active_signature:%s", signature.SignatureReferenceID) + err = s.storeRepository.DeleteActiveSignatureMetaData(ctx, key) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to remove active signature metadata for user: %s", signature.SignatureReferenceID) + return err + } + + //Get signed document + log.WithFields(f).Debugf("getting signed document for envelope ID: %s", envelopeID) + signedDocument, err := s.getSignedDocument(ctx, envelopeID, documentID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get signed document for envelope ID: %s", envelopeID) + return err + } + + // send email to user + log.WithFields(f).Debugf("sending email to user... ") + claGroup, err := s.claGroupService.GetCLAGroup(ctx, signature.ProjectID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup CLA Group by ID: %s", signature.ProjectID) + return err + } + + subject := fmt.Sprintf("EasyCLA: Individual CLA Signed for %s", claGroup.ProjectName) + pdfLink := fmt.Sprintf("%s/v3/signatures/%s/%s/icla/pdf", s.ClaV1ApiURL, signature.ProjectID, signature.SignatureReferenceID) + emailParams := emails.DocumentSignedTemplateParams{ + CommonEmailParams: emails.CommonEmailParams{ + RecipientName: fullName, + }, + PdfLink: pdfLink, + ICLA: true, + } + email := utils.GetBestEmail(claUser) + if email == "" { + log.WithFields(f).Warnf("unable to find email for user: %+v", claUser) + return errors.New("unable to find email for user") + } + + recipients := []string{utils.GetBestEmail(claUser)} + + body, err := emails.RenderDocumentSignedTemplate(s.emailTemplateService, claGroup.Version, claGroup.ProjectID, emailParams) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to render document signed template for project version: %s, project ID: %s", claGroup.Version, claGroup.ProjectID) + return err + } + + // send email to user + log.WithFields(f).Debugf("sending email to user... ") + err = utils.SendEmail(subject, body, recipients) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to send email to user: %s", claUser.Username) + return err + } + + log.WithFields(f).Debugf("email sent to user: %s", claUser.Username) + + if claUser.UserID == "" { + return fmt.Errorf("user id is empty for user: %s", claUser.Username) + } + + // store document on S3 + log.WithFields(f).Debugf("storing signed document on S3...") + err = utils.UploadToS3(signedDocument, signature.ProjectID, utils.ClaTypeICLA, claUser.UserID, signature.SignatureID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to store signed document on S3") + return err + } + + // Log the event + log.WithFields(f).Debugf("logging event...") + s.eventsService.LogEvent(&events.LogEventArgs{ + EventType: events.IndividualSignatureSigned, + ProjectID: signature.ProjectID, + UserID: claUser.UserID, + EventData: &events.IndividualSignatureSignedEventData{ + ProjectName: claGroup.ProjectName, + Username: fullName, + ProjectID: signature.ProjectID, + }, + CLAGroupID: signature.ProjectID, + }) + + } else { + log.WithFields(f).Debugf("envelope not signed - status: %s", status) + } return nil } +func fetchFullName(info DocuSignEnvelopeInformation) string { + var fullName string + for _, tabStatus := range info.EnvelopeStatus.RecipientStatuses[0].TabStatuses { + if tabStatus.TabLabel == "full_name" { + if tabStatus.TabValue != "" { + fullName = tabStatus.TabValue + } + } else if tabStatus.TabLabel == "signatory_name" { + if tabStatus.TabValue != "" { + fullName = tabStatus.TabValue + } + } + } + return fullName +} + func (s *service) SignedIndividualCallbackGitlab(ctx context.Context, payload []byte, userID, organizationID, mergeRequestID, repositoryID string) error { return nil } @@ -427,15 +627,18 @@ func (s *service) RequestIndividualSignature(ctx context.Context, input *models. // 3. Check for active signature object with this project. If the user has signed the most recent version they should not be able to sign again. log.WithFields(f).Debugf("checking for active signature object with this project...") - approved := true - signed := true - userSignatures, err := s.signatureService.GetIndividualSignatures(ctx, *input.ProjectID, *input.UserID, &approved, &signed) + sigParams := sigs.GetUserSignaturesParams{ + UserID: *input.UserID, + UserName: &user.Username, + } + userSignatures, err := s.signatureService.GetUserSignatures(ctx, sigParams, input.ProjectID) if err != nil { log.WithFields(f).WithError(err).Warnf("unable to lookup user signatures by user ID: %s", *input.UserID) return nil, err } - latestSignature := getLatestSignature(userSignatures) + log.WithFields(f).Debugf("found %d signatures for user: %s", len(userSignatures.Signatures), *input.UserID) + latestSignature := getLatestSignature(userSignatures.Signatures) // loading latest document log.WithFields(f).Debugf("loading latest individual document for project: %s", *input.ProjectID) @@ -454,6 +657,7 @@ func (s *service) RequestIndividualSignature(ctx context.Context, input *models. // creating individual default values log.WithFields(f).Debugf("creating individual default values...") defaultValues := s.createDefaultIndividualValues(user, preferredEmail) + log.WithFields(f).Debugf("default values: %+v", defaultValues) // 4. Generate signature callback url log.WithFields(f).Debugf("generating signature callback url...") @@ -570,6 +774,9 @@ func (s *service) RequestIndividualSignature(ctx context.Context, input *models. return nil, err } + signed := false + approved := true + itemSignature := signatures.ItemSignature{ SignatureID: signatureID, DateCreated: currentTime, @@ -732,9 +939,11 @@ func (s *service) getIndividualSignatureCallbackURL(ctx context.Context, userID return "", err } - callbackURL := fmt.Sprintf("%s/v4/signed/individual/%d/%s/%s", s.ClaV4ApiURL, installationId, repositoryID, pullRequestID) + baseURL := "https://7de6-197-221-137-205.ngrok-free.app" - log.WithFields(f).Debugf("return url: %s", callbackURL) + callbackURL := fmt.Sprintf("%s/v4/signed/individual/%d/%s/%s", baseURL, installationId, repositoryID, pullRequestID) + + log.WithFields(f).Debugf("callback url: %s", callbackURL) return callbackURL, nil @@ -1293,7 +1502,7 @@ func (s *service) createDefaultIndividualValues(user *v1Models.User, preferredEm f := logrus.Fields{ "functionName": "sign.createDefaultIndiviualValues", } - log.WithFields(f).Debugf("creating individual default values...") + log.WithFields(f).Debugf("creating individual default values...for user : %+v", user) defaultValues := make(map[string]interface{}) diff --git a/cla-backend-go/v2/signatures/handlers.go b/cla-backend-go/v2/signatures/handlers.go index e8788dc81..ba2516c79 100644 --- a/cla-backend-go/v2/signatures/handlers.go +++ b/cla-backend-go/v2/signatures/handlers.go @@ -681,7 +681,7 @@ func Configure(api *operations.EasyclaAPI, claGroupService service.Service, proj PageSize: params.PageSize, UserName: params.UserName, UserID: params.UserID, - }) + }, nil) if err != nil { msg := fmt.Sprintf("error retrieving user signatures for userID: %s", params.UserID) log.WithFields(f).WithError(err).Warn(msg) diff --git a/cla-backend-go/v2/store/repository.go b/cla-backend-go/v2/store/repository.go index ef25774ab..cebd30f38 100644 --- a/cla-backend-go/v2/store/repository.go +++ b/cla-backend-go/v2/store/repository.go @@ -30,6 +30,7 @@ type DBStore struct { type Repository interface { SetActiveSignatureMetaData(ctx context.Context, key string, expire int64, value string) error GetActiveSignatureMetaData(ctx context.Context, UserId string) (map[string]interface{}, error) + DeleteActiveSignatureMetaData(ctx context.Context, key string) error } type repo struct { @@ -175,3 +176,31 @@ func (r repo) SetActiveSignatureMetaData(ctx context.Context, key string, expire return nil } + +func (r repo) DeleteActiveSignatureMetaData(ctx context.Context, key string) error { + f := logrus.Fields{ + "functionName": "v2.store.repository.DeleteActiveSignatureMetaData", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "key": key, + } + + log.WithFields(f).Debugf("key: %s ", key) + + _, err := r.dynamoDBClient.DeleteItem(&dynamodb.DeleteItemInput{ + Key: map[string]*dynamodb.AttributeValue{ + "key": { + S: &key, + }, + }, + TableName: &r.storeTableName, + }) + + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to delete store record") + return err + } + + log.WithFields(f).Debugf("Signature meta record data deleted: %+v ", key) + + return nil +} diff --git a/cla-backend/cla/models/docusign_models.py b/cla-backend/cla/models/docusign_models.py index 45a64e9df..096b645ef 100644 --- a/cla-backend/cla/models/docusign_models.py +++ b/cla-backend/cla/models/docusign_models.py @@ -156,7 +156,7 @@ def request_individual_signature(self, project_id, user_id, return_url=None, ret return {'errors': {'project_id': str(err)}} # Check for active signature object with this project. If the user has - # signed the most recent major version, they do not need to fsign again. + # signed the most recent major version, they do not need to sign again. cla.log.debug('Individual Signature - loading latest user signature for user: {}, project: {}'. format(user, project)) latest_signature = user.get_latest_signature(str(project_id))