diff --git a/cla-backend-go/cmd/server.go b/cla-backend-go/cmd/server.go index c9c73c6e1..56e4d108a 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.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey) + v2SignService := sign.NewService(configFile.ClaV1ApiURL, v1CompanyRepo, v1CLAGroupRepo, v1ProjectClaGroupRepo, v1CompanyService, v2ClaGroupService, configFile.DocuSignPrivateKey, usersService, v1SignaturesService, storeRepository, v1RepositoriesService, githubOrganizationsService, gitlabOrganizationsService) 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/github/github_repository.go b/cla-backend-go/github/github_repository.go index b88f12f96..651798a6e 100644 --- a/cla-backend-go/github/github_repository.go +++ b/cla-backend-go/github/github_repository.go @@ -643,3 +643,38 @@ func CreateStatus(ctx context.Context, client *github.Client, owner, repo, sha s return c, resp, nil } + +func GetReturnURL(ctx context.Context, installationID, repositoryID int64, pullRequestID int) (string, error) { + f := logrus.Fields{ + "functionName": "github.github_repository.GetReturnURL", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "installationID": installationID, + "repositoryID": repositoryID, + "pullRequestID": pullRequestID, + } + + client, err := NewGithubAppClient(installationID) + + if err != nil { + log.WithFields(f).WithError(err).Warn("unable to create Github client") + return "", err + } + + log.WithFields(f).Debugf("getting github repository by id: %d", repositoryID) + repo, _, err := client.Repositories.GetByID(ctx, repositoryID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get repository by ID: %d", repositoryID) + return "", err + } + + log.WithFields(f).Debugf("getting pull request by id: %d", pullRequestID) + pullRequest, _, err := client.PullRequests.Get(ctx, *repo.Owner.Login, *repo.Name, pullRequestID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get pull request by ID: %d", pullRequestID) + return "", err + } + + log.WithFields(f).Debugf("returning pull request html url: %s", *pullRequest.HTMLURL) + + return *pullRequest.HTMLURL, nil +} diff --git a/cla-backend-go/project/common/helpers.go b/cla-backend-go/project/common/helpers.go index 4a6e8a5a5..111b393c8 100644 --- a/cla-backend-go/project/common/helpers.go +++ b/cla-backend-go/project/common/helpers.go @@ -102,13 +102,13 @@ func GetCurrentDocument(ctx context.Context, docs []models.ClaGroupDocument) (mo continue } - // No previous, use the first... - if currentDoc == (models.ClaGroupDocument{}) { - currentDoc = doc - currentDocVersion = version - currentDocDateTime = dateTime - continue - } + // // No previous, use the first... + // if currentDoc == (models.ClaGroupDocument{}) { + // currentDoc = doc + // currentDocVersion = version + // currentDocDateTime = dateTime + // continue + // } // Newer version... if version > currentDocVersion { @@ -127,3 +127,16 @@ func GetCurrentDocument(ctx context.Context, docs []models.ClaGroupDocument) (mo return currentDoc, nil } + +func AreClaGroupDocumentsEqual(doc1, doc2 models.ClaGroupDocument) bool { + return doc1.DocumentName == doc2.DocumentName && + doc1.DocumentAuthorName == doc2.DocumentAuthorName && + doc1.DocumentContentType == doc2.DocumentContentType && + doc1.DocumentFileID == doc2.DocumentFileID && + doc1.DocumentLegalEntityName == doc2.DocumentLegalEntityName && + doc1.DocumentPreamble == doc2.DocumentPreamble && + doc1.DocumentS3URL == doc2.DocumentS3URL && + doc1.DocumentMajorVersion == doc2.DocumentMajorVersion && + doc1.DocumentMinorVersion == doc2.DocumentMinorVersion && + doc1.DocumentCreationDate == doc2.DocumentCreationDate +} diff --git a/cla-backend-go/project/service/service.go b/cla-backend-go/project/service/service.go index df4fc8c07..f6a303e35 100644 --- a/cla-backend-go/project/service/service.go +++ b/cla-backend-go/project/service/service.go @@ -212,8 +212,8 @@ func (s ProjectService) GetCLAGroupCurrentICLATemplateURLByID(ctx context.Contex } } - if currentDoc == (models.ClaGroupDocument{}) { - log.WithFields(f).WithError(err).Warn("problem determining current ICLA for this CLA Group") + if common.AreClaGroupDocumentsEqual(currentDoc, models.ClaGroupDocument{}) { + log.WithFields(f).WithError(err).Warn("problem determining current ICLA for this CLA Group - document is empty") return "", &utils.CLAGroupICLANotConfigured{ CLAGroupID: claGroupID, CLAGroupName: claGroupModel.ProjectName, @@ -288,8 +288,8 @@ func (s ProjectService) GetCLAGroupCurrentCCLATemplateURLByID(ctx context.Contex } } - if currentDoc == (models.ClaGroupDocument{}) { - log.WithFields(f).WithError(err).Warn("problem determining current CCLA for this CLA Group") + if common.AreClaGroupDocumentsEqual(currentDoc, models.ClaGroupDocument{}) { + log.WithFields(f).WithError(err).Warn("problem determining current CCLA for this CLA Group - document is empty") return "", &utils.CLAGroupCCLANotConfigured{ CLAGroupID: claGroupID, CLAGroupName: claGroupModel.ProjectName, diff --git a/cla-backend-go/serverless.yml b/cla-backend-go/serverless.yml index 764d8daf5..f06aaf8f6 100644 --- a/cla-backend-go/serverless.yml +++ b/cla-backend-go/serverless.yml @@ -239,6 +239,7 @@ provider: DOCUSIGN_INTEGRATOR_KEY: ${file(./env.json):docusign-integrator-key, ssm:/cla-docusign-integrator-key-${opt:stage}} DOCUSIGN_AUTH_SERVER: ${file(./env.json):docusign-auth-server, ssm:/cla-docusign-auth-server-${opt:stage}} DOCUSIGN_USER_ID: ${file(./env.json):docusign-auth-server, ssm:/cla-docusign-user-id-${opt:stage}} + DOCUSIGN_ACCOUNT_ID: ${file(./env.json):docusign-account-id, ssm:/cla-docusign-account-id-${opt:stage}} CLA_API_BASE: ${file(./env.json):cla-api-base, ssm:/cla-api-base-${opt:stage}} CLA_CONTRIBUTOR_BASE: ${file(./env.json):cla-contributor-base, ssm:/cla-contributor-base-${opt:stage}} CLA_CONTRIBUTOR_V2_BASE: ${file(./env.json):cla-contributor-v2-base, ssm:/cla-contributor-v2-base-${opt:stage}} diff --git a/cla-backend-go/signatures/converters.go b/cla-backend-go/signatures/converters.go index c09ccb0a4..ef1b10b5c 100644 --- a/cla-backend-go/signatures/converters.go +++ b/cla-backend-go/signatures/converters.go @@ -98,6 +98,11 @@ func (repo repository) buildProjectSignatureModels(ctx context.Context, results UserDocusignName: dbSignature.UserDocusignName, UserDocusignDateSigned: dbSignature.UserDocusignDateSigned, AutoCreateECLA: dbSignature.AutoCreateECLA, + SignatureSignURL: dbSignature.SignatureSignURL, + SignatureCallbackURL: dbSignature.SignatureCallbackURL, + SignatureReturnURL: dbSignature.SignatureReturnURL, + SignatureReturnURLType: dbSignature.SignatureReturnURLType, + SignatureEnvelopeID: dbSignature.SignatureEnvelopeID, } sigs = append(sigs, sig) diff --git a/cla-backend-go/signatures/dbmodels.go b/cla-backend-go/signatures/dbmodels.go index 8f606a067..a5e46be5a 100644 --- a/cla-backend-go/signatures/dbmodels.go +++ b/cla-backend-go/signatures/dbmodels.go @@ -12,12 +12,17 @@ type ItemSignature struct { SignatureSigned bool `json:"signature_signed"` SignatureDocumentMajorVersion string `json:"signature_document_major_version"` SignatureDocumentMinorVersion string `json:"signature_document_minor_version"` + SignatureSignURL string `json:"signature_sign_url"` + SignatureReturnURL string `json:"signature_return_url"` + SignatureReturnURLType string `json:"signature_return_url_type"` + SignatureCallbackURL string `json:"signature_callback_url"` SignatureReferenceID string `json:"signature_reference_id"` SignatureReferenceName string `json:"signature_reference_name"` SignatureReferenceNameLower string `json:"signature_reference_name_lower"` SignatureProjectID string `json:"signature_project_id"` SignatureReferenceType string `json:"signature_reference_type"` SignatureType string `json:"signature_type"` + SignatureEnvelopeID string `json:"signature_envelope_id"` SignatureUserCompanyID string `json:"signature_user_ccla_company_id"` EmailApprovalList []string `json:"email_whitelist"` EmailDomainApprovalList []string `json:"domain_whitelist"` diff --git a/cla-backend-go/signatures/repository.go b/cla-backend-go/signatures/repository.go index 7473c5be5..dddef56a4 100644 --- a/cla-backend-go/signatures/repository.go +++ b/cla-backend-go/signatures/repository.go @@ -69,10 +69,12 @@ type SignatureRepository interface { DeleteGithubOrganizationFromApprovalList(ctx context.Context, signatureID, githubOrganizationID string) ([]models.GithubOrg, error) ValidateProjectRecord(ctx context.Context, signatureID, note string) error InvalidateProjectRecord(ctx context.Context, signatureID, note string) error + CreateOrUpdateSignature(ctx context.Context, signature *models.Signature) (*models.Signature, error) GetSignature(ctx context.Context, signatureID string) (*models.Signature, error) GetActivePullRequestMetadata(ctx context.Context, gitHubAuthorUsername, gitHubAuthorEmail string) (*ActivePullRequest, error) GetIndividualSignature(ctx context.Context, claGroupID, userID string, approved, signed *bool) (*models.Signature, error) + GetIndividualSignatures(ctx context.Context, claGroupID, userID string, approved, signed *bool) ([]*models.Signature, error) GetCorporateSignature(ctx context.Context, claGroupID, companyID string, approved, signed *bool) (*models.Signature, error) GetSignatureACL(ctx context.Context, signatureID string) ([]string, error) GetProjectSignatures(ctx context.Context, params signatures.GetProjectSignaturesParams) (*models.Signatures, error) @@ -443,6 +445,40 @@ func (repo repository) GetSignature(ctx context.Context, signatureID string) (*m return signatureList[0], nil } +// CreateOrUpdateSignature either creates or updates the signature record +func (repo repository) CreateOrUpdateSignature(ctx context.Context, signature *models.Signature) (*models.Signature, error) { + f := logrus.Fields{ + "functionName": "v1.signatures.repository.CreateOrUpdateSignature", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + } + + // Check if we have an existing signature record + existingSignature, sigErr := repo.GetSignature(ctx, signature.SignatureID) + if sigErr != nil { + log.WithFields(f).Warnf("error retrieving signature by ID: %s, error: %v", signature.SignatureID, sigErr) + return nil, sigErr + } + + // If we have an existing signature record, we need to update it + if existingSignature != nil { + log.WithFields(f).Debugf("updating existing signature record for signature ID: %s", signature.SignatureID) + return repo.updateSignature(ctx, signature) + } + + return nil, nil +} + +// updateSignature updates the specified signature record +func (repo repository) updateSignature(ctx context.Context, signature *models.Signature) (*models.Signature, error) { + // f := logrus.Fields{ + // "functionName": "v1.signatures.repository.updateSignature", + // utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + // } + + // // Update the record in the database + return nil, nil +} + // GetIndividualSignature returns the signature record for the specified CLA Group and User func (repo repository) GetIndividualSignature(ctx context.Context, claGroupID, userID string, approved, signed *bool) (*models.Signature, error) { f := logrus.Fields{ @@ -558,6 +594,121 @@ func (repo repository) GetIndividualSignature(ctx context.Context, claGroupID, u return sigs[0], nil // nolint G602: Potentially accessing slice out of bounds (gosec) } +// GetIndividualSignature returns the signature record for the specified CLA Group and User +func (repo repository) GetIndividualSignatures(ctx context.Context, claGroupID, userID string, approved, signed *bool) ([]*models.Signature, error) { + f := logrus.Fields{ + "functionName": "v1.signatures.repository.GetIndividualSignature", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "tableName": repo.signatureTableName, + "claGroupID": claGroupID, + "userID": userID, + "signatureType": utils.SignatureTypeCLA, + "signatureReferenceType": utils.SignatureReferenceTypeUser, + "signatureApproved": "true", + "signatureSigned": "true", + } + + log.WithFields(f).Debug("querying signature for icla records ...") + + var filterAdded bool + // These are the keys we want to match for an ICLA Signature with a given CLA Group and User ID + condition := expression.Key("signature_project_id").Equal(expression.Value(claGroupID)). + And(expression.Key("signature_reference_id").Equal(expression.Value(userID))) + var filter expression.ConditionBuilder + filter = addAndCondition(filter, expression.Name("signature_type").Equal(expression.Value(utils.SignatureTypeCLA)), &filterAdded) + filter = addAndCondition(filter, expression.Name("signature_reference_type").Equal(expression.Value(utils.SignatureReferenceTypeUser)), &filterAdded) + filter = addAndCondition(filter, expression.Name("signature_user_ccla_company_id").AttributeNotExists(), &filterAdded) + + if approved != nil { + filterAdded = true + searchTermExpression := expression.Name("signature_approved").Equal(expression.Value(aws.BoolValue(approved))) + filter = addAndCondition(filter, searchTermExpression, &filterAdded) + } + if signed != nil { + filterAdded = true + log.WithFields(f).Debugf("adding filter signature_signed: %t", aws.BoolValue(signed)) + searchTermExpression := expression.Name("signature_signed").Equal(expression.Value(aws.BoolValue(signed))) + filter = addAndCondition(filter, searchTermExpression, &filterAdded) + } + + // If no query option was provided for approved and signed and our configuration default is to only show active signatures then we add the required query filters + if approved == nil && signed == nil && config.GetConfig().SignatureQueryDefault == utils.SignatureQueryDefaultActive { + filterAdded = true + // log.WithFields(f).Debug("adding filter signature_approved: true and signature_signed: true") + filter = addAndCondition(filter, expression.Name("signature_approved").Equal(expression.Value(true)), &filterAdded) + filter = addAndCondition(filter, expression.Name("signature_signed").Equal(expression.Value(true)), &filterAdded) + } + + builder := expression.NewBuilder(). + WithKeyCondition(condition). + WithFilter(filter). + WithProjection(buildProjection()) + + // Use the nice builder to create the expression + expr, err := builder.Build() + if err != nil { + log.WithFields(f).Warnf("error building expression for project ICLA signature query, error: %v", err) + return nil, err + } + + // Assemble the query input parameters + queryInput := &dynamodb.QueryInput{ + ExpressionAttributeNames: expr.Names(), + ExpressionAttributeValues: expr.Values(), + KeyConditionExpression: expr.KeyCondition(), + ProjectionExpression: expr.Projection(), + FilterExpression: expr.Filter(), + TableName: aws.String(repo.signatureTableName), + Limit: aws.Int64(100), // The maximum number of items to evaluate (not necessarily the number of matching items) + IndexName: aws.String(SignatureProjectReferenceIndex), // Name of a secondary index to scan + } + + sigs := make([]*models.Signature, 0) + var lastEvaluatedKey string + + // Loop until we have all the records + for ok := true; ok; ok = lastEvaluatedKey != "" { + // Make the DynamoDB Query API call + results, errQuery := repo.dynamoDBClient.Query(queryInput) + //log.WithFields(f).Debugf("Ran signature project query, results: %+v, error: %+v", results, errQuery) + if errQuery != nil { + log.WithFields(f).Warnf("error retrieving project ICLA signature ID, error: %v", errQuery) + return nil, errQuery + } + + // Convert the list of DB models to a list of response models + //log.WithFields(f).Debug("Building response models...") + signatureList, modelErr := repo.buildProjectSignatureModels(ctx, results, claGroupID, LoadACLDetails) + if modelErr != nil { + log.WithFields(f).Warnf("error converting DB model to response model for signatures, error: %v", + modelErr) + return nil, modelErr + } + + // Add to the signatures response model to the list + sigs = append(sigs, signatureList...) + + //log.WithFields(f).Debugf("LastEvaluatedKey: %+v", results.LastEvaluatedKey) + if results.LastEvaluatedKey["signature_id"] != nil { + lastEvaluatedKey = *results.LastEvaluatedKey["signature_id"].S + queryInput.ExclusiveStartKey = results.LastEvaluatedKey + } else { + lastEvaluatedKey = "" + } + } + + // Didn't find a matching record + if len(sigs) == 0 { + return nil, nil + } + + if len(sigs) > 1 { + log.WithFields(f).Warnf("found multiple matching ICLA signatures - found %d total", len(sigs)) + } + + return sigs, nil +} + // GetCorporateSignature returns the signature record for the specified CLA Group and Company ID func (repo repository) GetCorporateSignature(ctx context.Context, claGroupID, companyID string, approved, signed *bool) (*models.Signature, error) { f := logrus.Fields{ diff --git a/cla-backend-go/signatures/service.go b/cla-backend-go/signatures/service.go index 9a37e4a33..959004a17 100644 --- a/cla-backend-go/signatures/service.go +++ b/cla-backend-go/signatures/service.go @@ -42,6 +42,7 @@ import ( type SignatureService interface { GetSignature(ctx context.Context, signatureID string) (*models.Signature, error) GetIndividualSignature(ctx context.Context, claGroupID, userID string, approved, signed *bool) (*models.Signature, error) + GetIndividualSignatures(ctx context.Context, claGroupID, userID string, approved, signed *bool) ([]*models.Signature, error) GetCorporateSignature(ctx context.Context, claGroupID, companyID string, approved, signed *bool) (*models.Signature, error) GetProjectSignatures(ctx context.Context, params signatures.GetProjectSignaturesParams) (*models.Signatures, error) CreateProjectSummaryReport(ctx context.Context, params signatures.CreateProjectSummaryReportParams) (*models.SignatureReport, error) @@ -67,6 +68,7 @@ type SignatureService interface { createOrGetEmployeeModels(ctx context.Context, claGroupModel *models.ClaGroup, companyModel *models.Company, corporateSignatureModel *models.Signature) ([]*models.User, error) CreateOrUpdateEmployeeSignature(ctx context.Context, claGroupModel *models.ClaGroup, companyModel *models.Company, corporateSignatureModel *models.Signature) ([]*models.User, error) + CreateOrUpdateSignature(ctx context.Context, signature *models.Signature) (*models.Signature, error) handleGitHubStatusUpdate(ctx context.Context, employeeUserModel *models.User) error } @@ -113,6 +115,11 @@ func (s service) GetIndividualSignature(ctx context.Context, claGroupID, userID return s.repo.GetIndividualSignature(ctx, claGroupID, userID, approved, signed) } +// GetIndividualSignatures returns the list of signatures associated with the specified CLA Group and User ID +func (s service) GetIndividualSignatures(ctx context.Context, claGroupID, userID string, approved, signed *bool) ([]*models.Signature, error) { + return s.repo.GetIndividualSignatures(ctx, claGroupID, userID, approved, signed) +} + // GetCorporateSignature returns the signature associated with the specified CLA Group and Company ID func (s service) GetCorporateSignature(ctx context.Context, claGroupID, companyID string, approved, signed *bool) (*models.Signature, error) { return s.repo.GetCorporateSignature(ctx, claGroupID, companyID, approved, signed) @@ -129,6 +136,11 @@ func (s service) GetProjectSignatures(ctx context.Context, params signatures.Get return projectSignatures, nil } +// CreateOrUpdateEmployeeSignature creates or updates the specified signature +func (s service) CreateOrUpdateSignature(ctx context.Context, signature *models.Signature) (*models.Signature, error) { + return s.repo.CreateOrUpdateSignature(ctx, signature) +} + // CreateProjectSummaryReport generates a project summary report based on the specified input func (s service) CreateProjectSummaryReport(ctx context.Context, params signatures.CreateProjectSummaryReportParams) (*models.SignatureReport, error) { diff --git a/cla-backend-go/swagger/cla.v1.yaml b/cla-backend-go/swagger/cla.v1.yaml index beaa11886..93579b54b 100644 --- a/cla-backend-go/swagger/cla.v1.yaml +++ b/cla-backend-go/swagger/cla.v1.yaml @@ -2854,6 +2854,9 @@ definitions: cla-group-document: $ref: './common/cla-group-document.yaml' + + document-tab: + $ref: './common/document-tab.yaml' create-cla-group-template: $ref: './common/create-cla-group-template.yaml' diff --git a/cla-backend-go/swagger/cla.v2.yaml b/cla-backend-go/swagger/cla.v2.yaml index 919cb4c47..9832825d7 100644 --- a/cla-backend-go/swagger/cla.v2.yaml +++ b/cla-backend-go/swagger/cla.v2.yaml @@ -5605,6 +5605,12 @@ definitions: sign_url: type: string description: signing url + user_id: + type: string + description: easyCLA user identification + project_id: + type: string + description: clagroup ID signed_document: type: object @@ -5674,6 +5680,9 @@ definitions: cla-group-summary: $ref: './common/cla-group-summary.yaml' + + document-tab: + $ref: './common/document-tab.yaml' cla-group-project: type: object diff --git a/cla-backend-go/swagger/common/cla-group-document.yaml b/cla-backend-go/swagger/common/cla-group-document.yaml index 8f6d9e8bb..70378861c 100644 --- a/cla-backend-go/swagger/common/cla-group-document.yaml +++ b/cla-backend-go/swagger/common/cla-group-document.yaml @@ -30,6 +30,9 @@ properties: description: the document content type example: 'storage+pdf' type: string + documentContent: + description: the document content + type: string documentS3URL: description: the document S3 URL example: "https://cla-signature-files-dev.s3.amazonaws.com/contract-group/f7222222-7777-4444-aaaa-1c1c1c1c1c1c/template/ccla.pdf" @@ -46,3 +49,7 @@ properties: description: the document creation date example: '2019-08-01T06:55:09Z' type: string + documentTabs: + type: array + items: + $ref: '#/definitions/document-tab' diff --git a/cla-backend-go/swagger/common/document-tab.yaml b/cla-backend-go/swagger/common/document-tab.yaml new file mode 100644 index 000000000..7ceabc47d --- /dev/null +++ b/cla-backend-go/swagger/common/document-tab.yaml @@ -0,0 +1,41 @@ +# Copyright The Linux Foundation and each contributor to CommunityBridge. +# SPDX-License-Identifier: MIT + +type: object +x-nullable: false +title: Docusign Document Tab +description: Docusign Document Tab +properties: + document_tab_type: + type: string + document_tab_id: + type: string + document_tab_name: + type: string + document_tab_page: + type: integer + document_tab_position_x: + type: integer + document_tab_position_y: + type: integer + document_tab_width: + type: integer + document_tab_height: + type: integer + document_tab_is_locked: + type: boolean + document_tab_is_required: + type: boolean + document_tab_anchor_string: + type: string + document_tab_anchor_ignore_if_not_present: + type: boolean + document_tab_anchor_x_offset: + type: integer + document_tab_anchor_y_offset: + type: integer + + + + + diff --git a/cla-backend-go/swagger/common/signature.yaml b/cla-backend-go/swagger/common/signature.yaml index 57c764893..795eeb1ef 100644 --- a/cla-backend-go/swagger/common/signature.yaml +++ b/cla-backend-go/swagger/common/signature.yaml @@ -116,6 +116,27 @@ properties: type: string description: the signature minor version number example: '1' + signatureDocumentMajorVersion: + type: string + description: the signature documentt major version + signatureDocumentMinorVersion: + type: string + description: the signature document minor version + signatureSignURL: + type: string + description: the signature Document Sign URL + signatureCallbackURL: + type: string + description: the signature callback URL + signatureReturnURL: + type: string + description: the signature return URL + signatureReturnURLType: + type: string + description: the signature return URL type + signatureEnvelopeId: + type: string + description: the signature envelope ID emailApprovalList: type: array description: a list of zero or more email addresses in the approval list diff --git a/cla-backend-go/v2/sign/constants.go b/cla-backend-go/v2/sign/constants.go new file mode 100644 index 000000000..d8d31c2d8 --- /dev/null +++ b/cla-backend-go/v2/sign/constants.go @@ -0,0 +1,15 @@ +// Copyright The Linux Foundation and each contributor to LFX. +// SPDX-License-Identifier: MIT + +package sign + +const ( + //Github is a constant for github + Github = "github" + + // Unknown is a constant for unknown + Unknown = "Unknown" + + // Gitlab is a constant for gitlab + Gitlab = "gitlab" +) diff --git a/cla-backend-go/v2/sign/docusign.go b/cla-backend-go/v2/sign/docusign.go index a82fce53f..3e743b193 100644 --- a/cla-backend-go/v2/sign/docusign.go +++ b/cla-backend-go/v2/sign/docusign.go @@ -7,6 +7,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "strings" @@ -41,7 +42,7 @@ func (s *service) getAccessToken(ctx context.Context) (string, error) { return "", err } - url := utils.GetProperty("DOCUSIGN_AUTH_SERVER") + "/oauth/token" + url := fmt.Sprintf("https://%s/oauth/token", utils.GetProperty("DOCUSIGN_AUTH_SERVER")) req, err := http.NewRequest("POST", url, strings.NewReader(string(tokenRequestBodyJSON))) if err != nil { log.WithFields(f).WithError(err).Warnf("problem creating the HTTP request") @@ -88,3 +89,223 @@ func (s *service) getAccessToken(ctx context.Context) (string, error) { return tokenResponse.AccessToken, nil } + +// Void envelope +func (s *service) VoidEnvelope(ctx context.Context, envelopeID, message string) error { + f := logrus.Fields{ + "functionName": "v2.VoidEnvelope", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "envelopeID": envelopeID, + "message": message, + } + + accessToken, err := s.getAccessToken(ctx) + if err != nil { + log.WithFields(f).WithError(err).Warnf("problem getting the access token") + return err + } + + voidRequest := struct { + VoidReason string `json:"voidReason"` + }{ + VoidReason: message, + } + + voidRequestJSON, err := json.Marshal(voidRequest) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("problem marshalling the void request") + return err + } + + url := fmt.Sprintf("https://%s/restapi/v2.1/accounts/%s/envelopes/%s/void", utils.GetProperty("DOCUSIGN_ROOT_URL"), utils.GetProperty("DOCUSIGN_ACCOUNT_ID"), envelopeID) + + req, err := http.NewRequest("PUT", url, strings.NewReader(string(voidRequestJSON))) + + if err != nil { + return err + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Add("Content-Type", "application/json") + + // Make the request + client := &http.Client{} + + resp, err := client.Do(req) + + if err != nil { + return err + } + + defer func() { + if err = resp.Body.Close(); err != nil { + log.WithFields(f).WithError(err).Warnf("problem closing the response body") + } + }() + + _, err = io.ReadAll(resp.Body) + + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return errors.New("problem making the HTTP request") + } + + return nil + +} + +// Function to create a DocuSign envelope +func (s *service) PrepareSignRequest(ctx context.Context, signRequest *DocuSignEnvelopeRequest) (*DocuSignEnvelopeResponse, error) { + // Serialize the signRequest into JSON + requestJSON, err := json.Marshal(signRequest) + if err != nil { + return nil, err + } + + // Get the access token + accessToken, err := s.getAccessToken(ctx) + + if err != nil { + return nil, err + } + + // Create the request + + url := fmt.Sprintf("https://%s/restapi/v2.1/accounts/%s/envelopes", utils.GetProperty("DOCUSIGN_ROOT_URL"), utils.GetProperty("DOCUSIGN_ACCOUNT_ID")) + + req, err := http.NewRequest("POST", url, strings.NewReader(string(requestJSON))) + + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Add("Content-Type", "application/json") + + // Make the request + client := &http.Client{} + + resp, err := client.Do(req) + + if err != nil { + return nil, err + } + + defer func() { + if err = resp.Body.Close(); err != nil { + log.Warnf("problem closing the response body") + } + }() + + // Parse the response + responsePayload, err := io.ReadAll(resp.Body) + + if err != nil { + return nil, err + } + + if resp.StatusCode != http.StatusOK { + return nil, errors.New("problem making the HTTP request") + } + + var envelopeResponse DocuSignEnvelopeResponse + + err = json.Unmarshal(responsePayload, &envelopeResponse) + + if err != nil { + return nil, err + } + + return &envelopeResponse, nil +} + +// GetSignURL fetches the signing URL for the specified envelope and recipient + +func (s *service) GetSignURL(envelopeID, recipientID, returnURL string) (string, error) { + + f := logrus.Fields{ + "functionName": "v2.GetSignURL", + "envelopeID": envelopeID, + "recipientID": recipientID, + "returnURL": returnURL, + } + + // Get the access token + accessToken, err := s.getAccessToken(context.Background()) + + if err != nil { + return "", err + } + + // Create the request + + url := fmt.Sprintf("https://%s/restapi/v2.1/accounts/%s/envelopes/%s/views/recipient", utils.GetProperty("DOCUSIGN_ROOT_URL"), utils.GetProperty("DOCUSIGN_ACCOUNT_ID"), envelopeID) + + req, err := http.NewRequest("POST", url, nil) + + if err != nil { + return "", err + } + + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken)) + req.Header.Add("Content-Type", "application/json") + + // Create the request body + requestBody := struct { + ReturnURL string `json:"returnUrl"` + ClientUserID string `json:"clientUserId"` + RecipientID string `json:"recipientId"` + }{ + ReturnURL: returnURL, + ClientUserID: recipientID, + RecipientID: recipientID, + } + + requestBodyJSON, err := json.Marshal(requestBody) + + if err != nil { + return "", err + } + + req.Body = io.NopCloser(strings.NewReader(string(requestBodyJSON))) + + // Make the request + client := &http.Client{} + + resp, err := client.Do(req) + + if err != nil { + return "", err + } + + defer func() { + if err = resp.Body.Close(); err != nil { + log.WithFields(f).WithError(err).Warnf("problem closing the response body") + } + }() + + // Parse the response + + // Parse the response JSON + var response struct { + Url string `json:"url"` + } + + responsePayload, err := io.ReadAll(resp.Body) + + if err != nil { + return "", err + } + + err = json.Unmarshal(responsePayload, &response) + + if err != nil { + return "", err + } + + return response.Url, nil +} diff --git a/cla-backend-go/v2/sign/handlers.go b/cla-backend-go/v2/sign/handlers.go index fe279716d..fd6fd1119 100644 --- a/cla-backend-go/v2/sign/handlers.go +++ b/cla-backend-go/v2/sign/handlers.go @@ -5,14 +5,16 @@ package sign import ( "context" + "encoding/json" "errors" "fmt" + "net/http" "strings" log "github.com/communitybridge/easycla/cla-backend-go/logging" - "github.com/sirupsen/logrus" - "github.com/communitybridge/easycla/cla-backend-go/projects_cla_groups" + "github.com/sirupsen/logrus" + "golang.org/x/oauth2" "github.com/LF-Engineering/lfx-kit/auth" "github.com/communitybridge/easycla/cla-backend-go/gen/v2/models" @@ -89,10 +91,61 @@ func Configure(api *operations.EasyclaAPI, service Service) { "returnURLType": params.Input.ReturnURLType, "userID": params.Input.UserID, } - log.WithFields(f).Debug("processing request") - resp, err := service.RequestIndividualSignature(ctx, params.Input) + var resp *models.IndividualSignatureOutput + var err error + var preferredEmail string = "" + + session := getRequestSession(params.HTTPRequest) + if session == nil { + msg := "session not found" + log.WithFields(f).Warn(msg) + return sign.NewRequestIndividualSignatureBadRequest().WithPayload(errorResponse(reqId, errors.New(msg))) + } + + clientID := utils.GetProperty("GH_OAUTH_CLIENT_ID") + if clientID == "" { + msg := "client id not found" + log.WithFields(f).Warn(msg) + return sign.NewRequestIndividualSignatureBadRequest().WithPayload(errorResponse(reqId, errors.New(msg))) + } + + if strings.ToLower(params.Input.ReturnURLType) == Github || strings.ToLower(params.Input.ReturnURLType) == Gitlab { + if strings.ToLower(params.Input.ReturnURLType) == Github { + log.WithFields(f).Debug("fetching github emails") + emails, fetchErr := fetchGithubEmails(session, clientID) + if fetchErr != nil { + return sign.NewRequestIndividualSignatureBadRequest().WithPayload(errorResponse(reqId, err)) + } + + if len(emails) == 0 { + msg := "no emails found" + log.WithFields(f).Warn(msg) + return sign.NewRequestIndividualSignatureBadRequest().WithPayload(errorResponse(reqId, errors.New(msg))) + } + for _, email := range emails { + if email["verified"].(bool) && email["primary"].(bool) { + if emailVal, ok := email["email"].(string); ok { + preferredEmail = emailVal + } + break + } + } + } else { + log.WithFields(f).Debug("fetching gitlab emails") + preferredEmail = "" //TODO: fetch gitlab emails for gitlab + } + + log.WithFields(f).Debug("requesting individual signature for github/gitlab") + resp, err = service.RequestIndividualSignature(ctx, params.Input, preferredEmail) + } else if strings.ToLower(params.Input.ReturnURLType) == "gerrit" { + log.WithFields(f).Debug("requesting individual signature for gerrit") + resp, err = service.RequestIndividualSignatureGerrit(ctx, params.Input) + } else { + msg := fmt.Sprintf("invalid return URL type: %s", params.Input.ReturnURLType) + log.WithFields(f).Warn(msg) + return sign.NewRequestIndividualSignatureBadRequest().WithPayload(errorResponse(reqId, errors.New(msg))) + } if err != nil { - log.WithFields(f).WithError(err).Warn("problem requesting individual signature") return sign.NewRequestIndividualSignatureBadRequest().WithPayload(errorResponse(reqId, err)) } return sign.NewRequestIndividualSignatureOK().WithPayload(resp) @@ -118,3 +171,58 @@ func errorResponse(reqID string, err error) *models.ErrorResponse { return &e } + +func getRequestSession(req *http.Request) map[string]interface{} { + session := req.Context().Value("session") + if session == nil { + return nil + } + return session.(map[string]interface{}) +} + +func fetchGithubEmails(session map[string]interface{}, clientID string) ([]map[string]interface{}, error) { + var emails []map[string]interface{} + var token string + + if tokenVal, ok := session["token"].(string); ok { + token = tokenVal + } else { + return emails, nil + } + + if token == "" { + return emails, nil + } + + oauth2Config := oauth2.Config{ + ClientID: clientID, + } + + oauth2Token := &oauth2.Token{ + AccessToken: token, + } + + client := oauth2Config.Client(context.Background(), oauth2Token) + + resp, err := client.Get("https://api.github.com/user/emails") + if err != nil { + return emails, err + } + + defer func() { + if err = resp.Body.Close(); err != nil { + log.Warnf("problem closing the response body") + } + }() + + if resp.StatusCode != 200 { + return emails, err + } + + err = json.NewDecoder(resp.Body).Decode(&emails) + if err != nil { + return emails, err + } + + return emails, err +} diff --git a/cla-backend-go/v2/sign/models.go b/cla-backend-go/v2/sign/models.go index a7fc65635..0d27aed26 100644 --- a/cla-backend-go/v2/sign/models.go +++ b/cla-backend-go/v2/sign/models.go @@ -40,17 +40,18 @@ type DocuSignUserInfoResponse struct { // DocuSignEnvelopeRequest is the request body for an envelope from DocuSign, see: https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/envelopes/create/ type DocuSignEnvelopeRequest struct { - EnvelopeId string `json:"envelopeId,omitempty"` // The envelope ID of the envelope - EnvelopeIdStamping string `json:"envelopeIdStamping,omitempty"` // When true, Envelope ID Stamping is enabled. After a document or attachment is stamped with an Envelope ID, the ID is seen by all recipients and becomes a permanent part of the document and cannot be removed. - TemplateId string `json:"templateId,omitempty"` // The ID of the template. If a value is not provided, DocuSign generates a value. - Documents []DocuSignDocument `json:"document,omitempty"` // A data model containing details about the documents associated with the envelope - DocumentBase64 string `json:"documentBase64,omitempty"` // The document's bytes. This field can be used to include a base64 version of the document bytes within an envelope definition instead of sending the document using a multi-part HTTP request. The maximum document size is smaller if this field is used due to the overhead of the base64 encoding. - DocumentsCombinedUri string `json:"documentsCombinedUri,omitempty"` // The URI for retrieving all of the documents associated with the envelope as a single PDF file. - DocumentsUri string `json:"documentsUri,omitempty"` // The URI for retrieving all of the documents associated with the envelope as separate files. - EmailSubject string `json:"emailSubject,omitempty"` // EmailSubject - The subject line of the email message that is sent to all recipients. - EmailBlurb string `json:"emailBlurb,omitempty"` // EmailBlurb - This is the same as the email body. If specified it is included in email body for all envelope recipients. - Recipients DocuSignRecipientType `json:"recipients,omitempty"` - TemplateRoles []DocuSignTemplateRole `json:"templateRoles,omitempty"` + EnvelopeId string `json:"envelopeId,omitempty"` // The envelope ID of the envelope + EnvelopeIdStamping string `json:"envelopeIdStamping,omitempty"` // When true, Envelope ID Stamping is enabled. After a document or attachment is stamped with an Envelope ID, the ID is seen by all recipients and becomes a permanent part of the document and cannot be removed. + TemplateId string `json:"templateId,omitempty"` // The ID of the template. If a value is not provided, DocuSign generates a value. + Documents []DocuSignDocument `json:"document,omitempty"` // A data model containing details about the documents associated with the envelope + DocumentBase64 string `json:"documentBase64,omitempty"` // The document's bytes. This field can be used to include a base64 version of the document bytes within an envelope definition instead of sending the document using a multi-part HTTP request. The maximum document size is smaller if this field is used due to the overhead of the base64 encoding. + DocumentsCombinedUri string `json:"documentsCombinedUri,omitempty"` // The URI for retrieving all of the documents associated with the envelope as a single PDF file. + DocumentsUri string `json:"documentsUri,omitempty"` // The URI for retrieving all of the documents associated with the envelope as separate files. + EmailSubject string `json:"emailSubject,omitempty"` // EmailSubject - The subject line of the email message that is sent to all recipients. + EmailBlurb string `json:"emailBlurb,omitempty"` // EmailBlurb - This is the same as the email body. If specified it is included in email body for all envelope recipients. + Recipients DocuSignRecipientType `json:"recipients,omitempty"` + TemplateRoles []DocuSignTemplateRole `json:"templateRoles,omitempty"` + EventNotification DocuSignEventNotification `json:"eventNotification,omitempty"` /* Status Indicates the envelope status. Valid values when creating an envelope are: @@ -129,6 +130,17 @@ type DocuSignRecipient struct { Tabs DocuSignTab `json:"tabs"` // The tabs associated with the recipient. The tabs property enables you to programmatically position tabs on the document. For example, you can specify that the SIGN_HERE tab is placed at a given (x,y) location on the document. You can also specify the font, font color, font size, and other properties of the text in the tab. You can also specify the location and size of the tab. For example, you can specify that the tab is 50 pixels wide and 20 pixels high. You can also specify the page number on which the tab is located and whether the tab is located in a document, a template, or an inline template. For more information about tabs, see the Tabs section of the REST API documentation. } +// TextOptionalTab + +type TextOptionalTab struct { + Name string `json:"name"` + Value string `json:"value"` + Height int `json:"height"` + Width int `json:"width"` + Locked bool `json:"locked"` + Required bool `json:"required"` +} + // DocuSignTab is the data model for a tab from DocuSign type DocuSignTab struct { ApproveTabs []DocuSignTabDetails `json:"approveTabs,omitempty"` @@ -170,6 +182,8 @@ type DocuSignTab struct { TitleTabs []DocuSignTabDetails `json:"titleTabs,omitempty"` ViewTabs []DocuSignTabDetails `json:"viewTabs,omitempty"` ZipTabs []DocuSignTabDetails `json:"zipTabs,omitempty"` + TextOptionalTabs []DocuSignTabDetails `json:"textOptionalTabs,omitempty"` + SignHereOptionalTabs []DocuSignTabDetails `json:"signHereOptionalTabs,omitempty"` } // DocuSignTabDetails is the data model for a tab from DocuSign @@ -204,6 +218,7 @@ type DocuSignTabDetails struct { YPosition string `json:"yPosition,omitempty"` // x position ValidationType string `json:"validationType,omitempty"` // validation type, "string", "number", "date", "zipcode", "currency" Value string `json:"value,omitempty"` + CustomTabId string `json:"customTabId,omitempty"` } // DocuSignTemplateRole is the request body for a template role from DocuSign @@ -217,7 +232,8 @@ type DocuSignTemplateRole struct { // DocuSignEnvelopeResponse is the response body for an envelope from DocuSign, see: https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/envelopes/update/ type DocuSignEnvelopeResponse struct { - EnvelopeId string `json:"envelopeId,omitempty"` + EnvelopeId string `json:"envelopeId,omitempty"` + Recipients []DocuSignRecipient `json:"recipients,omitempty"` ErrorDetails struct { ErrorCode string `json:"errorCode,omitempty"` Message string `json:"message,omitempty"` @@ -290,3 +306,29 @@ type IndividualMembershipDocuSignDBSummaryModel struct { Memo sql.NullString `db:"memo"` //DocuSignEnvelopeSignedDate time.Time `json:"docusign_envelope_signed_date"` } + +type ClaSignatoryEmailParams struct { + ClaGroupName string + SignatoryName string + ClaManagerName string + ClaManagerEmail string + CompanyName string + ProjectVersion string + ProjectNames []string +} + +type DocuSignRecipientEvent struct { + RecipientEventStatusCode string `json:"recipientEventStatusCode"` +} + +type DocuSignEventNotification struct { + URL string + LoggingEnabled bool + RecipientEvents []DocuSignRecipientEvent +} + +type Recipient struct { + Name string `json:"name"` + Email string `json:"email"` + // Other recipient-specific fields +} diff --git a/cla-backend-go/v2/sign/service.go b/cla-backend-go/v2/sign/service.go index e36f3eab6..6b015894f 100644 --- a/cla-backend-go/v2/sign/service.go +++ b/cla-backend-go/v2/sign/service.go @@ -6,15 +6,28 @@ package sign import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" + "net/url" + "strconv" "strings" + "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" "github.com/communitybridge/easycla/cla-backend-go/projects_cla_groups" + "github.com/communitybridge/easycla/cla-backend-go/repositories" + "github.com/communitybridge/easycla/cla-backend-go/signatures" + "github.com/communitybridge/easycla/cla-backend-go/users" "github.com/communitybridge/easycla/cla-backend-go/v2/cla_groups" + "github.com/communitybridge/easycla/cla-backend-go/v2/gitlab_organizations" + "github.com/communitybridge/easycla/cla-backend-go/v2/store" + "github.com/go-openapi/strfmt" + "github.com/gofrs/uuid" "github.com/sirupsen/logrus" @@ -37,6 +50,7 @@ import ( // constants const ( DontLoadRepoDetails = false + DocSignFalse = "false" ) // errors @@ -53,8 +67,13 @@ type ProjectRepo interface { // Service interface defines the sign service methods type Service interface { + VoidEnvelope(ctx context.Context, envelopeID, message string) error + PrepareSignRequest(ctx context.Context, signRequest *DocuSignEnvelopeRequest) (*DocuSignEnvelopeResponse, error) + GetSignURL(envelopeID, recipientID, returnURL string) (string, error) + RequestCorporateSignature(ctx context.Context, lfUsername string, authorizationHeader string, input *models.CorporateSignatureInput) (*models.CorporateSignatureOutput, error) - RequestIndividualSignature(ctx context.Context, input *models.IndividualSignatureInput) (*models.IndividualSignatureOutput, error) + RequestIndividualSignature(ctx context.Context, input *models.IndividualSignatureInput, preferredEmail string) (*models.IndividualSignatureOutput, error) + RequestIndividualSignatureGerrit(ctx context.Context, input *models.IndividualSignatureInput) (*models.IndividualSignatureOutput, error) } // service @@ -66,10 +85,17 @@ type service struct { companyService 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 } // NewService returns an instance of v2 project service -func NewService(apiURL string, compRepo company.IRepository, projectRepo ProjectRepo, pcgRepo projects_cla_groups.Repository, compService company.IService, claGroupService cla_groups.Service, docsignPrivateKey string) Service { +func NewService(apiURL 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 { return &service{ ClaV1ApiURL: apiURL, companyRepo: compRepo, @@ -78,6 +104,11 @@ func NewService(apiURL string, compRepo company.IRepository, projectRepo Project companyService: compService, claGroupService: claGroupService, docsignPrivateKey: docsignPrivateKey, + userService: userService, + signatureService: signatureService, + storeRepository: storeRepository, + githubOrgService: githubOrgService, + gitlabOrgService: gitlabOrgService, } } @@ -305,7 +336,7 @@ func (s *service) RequestCorporateSignature(ctx context.Context, lfUsername stri return out.toModel(), nil } -func (s *service) RequestIndividualSignature(ctx context.Context, input *models.IndividualSignatureInput) (*models.IndividualSignatureOutput, error) { +func (s *service) RequestIndividualSignature(ctx context.Context, input *models.IndividualSignatureInput, preferredEmail string) (*models.IndividualSignatureOutput, error) { f := logrus.Fields{ "functionName": "sign.RequestIndividualSignature", utils.XREQUESTID: ctx.Value(utils.XREQUESTID), @@ -315,13 +346,842 @@ func (s *service) RequestIndividualSignature(ctx context.Context, input *models. "userID": input.UserID, } - log.WithFields(f).Debug("Get Access Token for DocuSign") - accessToken, err := s.getAccessToken(ctx) + /** + 1. Ensure this is a valid user + 2. Ensure this is a valid project + 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. + 4. Generate signature callback url + 5. Get signature return URL + 6. Get latest document + 7. if the CCLA/ICLA template is missing we wont have a document and return an error + 8. Create new signature object + 9. Set signature ACL + 10. Populate sign url + 11. Save signature + **/ + + // 1. Ensure this is a valid user + log.WithFields(f).Debugf("looking up user by ID: %s", *input.UserID) + user, err := s.userService.GetUser(*input.UserID) + if err != nil || user == nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup user by ID: %s", *input.UserID) + return nil, err + } + + // 2. Ensure this is a valid project + log.WithFields(f).Debugf("looking up project by ID: %s", *input.ProjectID) + claGroup, err := s.claGroupService.GetCLAGroup(ctx, *input.ProjectID) + if err != nil || claGroup == nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup project by ID: %s", *input.ProjectID) + return nil, err + } + + // 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) + 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) + + // loading latest document + log.WithFields(f).Debugf("loading latest individual document for project: %s", *input.ProjectID) + latestDocument, err := common.GetCurrentDocument(ctx, claGroup.ProjectIndividualDocuments) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup latest individual document for project: %s", *input.ProjectID) + return nil, err + } + + if common.AreClaGroupDocumentsEqual(latestDocument, v1Models.ClaGroupDocument{}) { + log.WithFields(f).WithError(err).Warnf("unable to lookup latest individual document for project: %s", *input.ProjectID) + return nil, errors.New("unable to lookup latest individual document for project") + } + + // creating individual default values + log.WithFields(f).Debugf("creating individual default values...") + defaultValues := s.createDefaultIndividualValues(user, preferredEmail) + + // 4. Generate signature callback url + log.WithFields(f).Debugf("generating signature callback url...") + activeSignatureMetadata, err := s.storeRepository.GetActiveSignatureMetaData(ctx, *input.UserID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get active signature meta data for user: %s", *input.UserID) + return nil, err + } + + log.WithFields(f).Debugf("active signature metadata: %+v", activeSignatureMetadata) + + log.WithFields(f).Debugf("generating signature callback url...") + var callBackURL string + + if strings.ToLower(input.ReturnURLType) == "github" { + callBackURL, err = s.getIndividualSignatureCallbackURL(ctx, *input.UserID, activeSignatureMetadata) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get signature callback url for user: %s", *input.UserID) + return nil, err + } + } else if strings.ToLower(input.ReturnURLType) == "gitlab" { + callBackURL, err = s.getIndividualSignatureCallbackURLGitlab(ctx, *input.UserID, activeSignatureMetadata) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get signature callback url for user: %s", *input.UserID) + return nil, err + } + } + + log.WithFields(f).Debugf("signature callback url: %s", callBackURL) + + if latestSignature != nil { + if latestDocument.DocumentMajorVersion == latestSignature.SignatureDocumentMajorVersion { + + log.WithFields(f).Warnf("user: already has a signature with this project: %s", *input.ProjectID) + + // Regenerate and set the signing URL - This will update the signature record + log.WithFields(f).Debugf("regenerating signing URL for user: %s", *input.UserID) + err = s.populateSignURL(ctx, latestSignature, callBackURL, "", "", false, "", "", defaultValues, preferredEmail) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to populate sign url for user: %s", *input.UserID) + return nil, err + } + + return &models.IndividualSignatureOutput{ + SignURL: latestSignature.SignatureSignURL, + SignatureID: latestSignature.SignatureID, + UserID: latestSignature.SignatureReferenceID, + ProjectID: *input.ProjectID, + }, nil + } + } + + // 5. Get signature return URL + log.WithFields(f).Debugf("getting signature return url...") + var returnURL string + if input.ReturnURL.String() == "" { + log.WithFields(f).Warnf("signature return url is empty") + returnURL, err = getActiveSignatureReturnURL(*input.UserID, activeSignatureMetadata) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get active signature return url for user: %s", *input.UserID) + return nil, err + } + if returnURL == "" { + log.WithFields(f).Warnf("signature return url is empty") + return &models.IndividualSignatureOutput{ + UserID: *input.UserID, + ProjectID: *input.ProjectID, + }, nil + } + + } + + // 6. Get latest document + log.WithFields(f).Debugf("getting latest document...") + document, err := common.GetCurrentDocument(ctx, claGroup.ProjectIndividualDocuments) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get latest document for project: %s", *input.ProjectID) + return nil, err + } + + // 7. if the CCLA/ICLA template is missing we wont have a document and return an error + if common.AreClaGroupDocumentsEqual(document, v1Models.ClaGroupDocument{}) { + log.WithFields(f).WithError(err).Warnf("unable to get latest document for project: %s", *input.ProjectID) + return nil, errors.New("unable to get latest document for project") + } + + // 8. Create new signature object + log.WithFields(f).Debugf("creating new signature object...") + signatureID := uuid.Must(uuid.NewV4()).String() + _, currentTime := utils.CurrentTime() + + signatureModel := &v1Models.Signature{ + SignatureID: signatureID, + SignatureDocumentMajorVersion: document.DocumentMajorVersion, + SignatureDocumentMinorVersion: document.DocumentMinorVersion, + SignatureReferenceID: *input.UserID, + SignatureReferenceType: "user", + ProjectID: *input.ProjectID, + SignatureType: utils.SignatureTypeCLA, + SignatureCreated: currentTime, + SignatureModified: currentTime, + SignatureReturnURL: input.ReturnURL.String(), + SignatureCallbackURL: callBackURL, + SignatureReturnURLType: input.ReturnURLType, + } + + // 9. Set signature ACL + log.WithFields(f).Debugf("setting signature ACL...") + signatureModel.SignatureACL = []v1Models.User{ + *user, + } + + // 10. Populate sign url + log.WithFields(f).Debugf("populating sign url...") + err = s.populateSignURL(ctx, signatureModel, callBackURL, "", "", false, "", "", defaultValues, preferredEmail) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to populate sign url for user: %s", *input.UserID) + return nil, err + } + + // 11. Save signature + signature, err := s.signatureService.CreateOrUpdateSignature(ctx, signatureModel) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to create signature for user: %s", *input.UserID) + return nil, err + } + + return &models.IndividualSignatureOutput{ + UserID: signature.SignatureReferenceID, + ProjectID: signature.ProjectID, + SignatureID: signature.SignatureID, + }, nil +} + +func (s *service) getIndividualSignatureCallbackURLGitlab(ctx context.Context, userID string, metadata map[string]interface{}) (string, error) { + f := logrus.Fields{ + "functionName": "sign.getIndividualSignatureCallbackURLGitlab", + "userID": userID, + } + + log.WithFields(f).Debugf("generating signature callback url...") + var err error + var repositoryID string + var mergeRequestID string + + if metadata == nil { + metadata, err = s.storeRepository.GetActiveSignatureMetaData(ctx, userID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get active signature meta data for user: %s", userID) + return "", err + } + } + + if found, ok := metadata["repository_id"].(string); ok { + repositoryID = found + } else { + log.WithFields(f).WithError(err).Warnf("unable to get repository ID for user: %s", userID) + return "", err + } + + if found, ok := metadata["pull_request_id"].(string); ok { + mergeRequestID = found + } else { + log.WithFields(f).WithError(err).Warnf("unable to get pull request ID for user: %s", userID) + return "", err + } + + gitlabOrg, err := s.gitlabOrgService.GetGitLabOrganization(ctx, repositoryID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get organization ID for repository ID: %s", repositoryID) + return "", err + } + + if gitlabOrg.OrganizationID == "" { + log.WithFields(f).WithError(err).Warnf("unable to get organization ID for repository ID: %s", repositoryID) + return "", err + } + + return fmt.Sprintf("%s/v2/signed/gitlab/individual/%s/%s/%s/%s", s.ClaV1ApiURL, userID, gitlabOrg.OrganizationID, repositoryID, mergeRequestID), nil + +} + +func (s *service) getIndividualSignatureCallbackURL(ctx context.Context, userID string, metadata map[string]interface{}) (string, error) { + f := logrus.Fields{ + "functionName": "sign.getIndividualSignatureCallbackURL", + "userID": userID, + } + + log.WithFields(f).Debugf("generating signature callback url...") + var err error + var installationId int64 + var repositoryID string + var pullRequestID int + + if metadata == nil { + metadata, err = s.storeRepository.GetActiveSignatureMetaData(ctx, userID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get active signature meta data for user: %s", userID) + return "", err + } + } + + if found, ok := metadata["repository_id"].(string); ok { + repositoryID = found + } else { + log.WithFields(f).WithError(err).Warnf("unable to get repository ID for user: %s", userID) + return "", err + } + + if found, ok := metadata["pull_request_id"].(int); ok { + pullRequestID = found + } else { + log.WithFields(f).WithError(err).Warnf("unable to get pull request ID for user: %s", userID) + return "", err + } + + // Get installation ID through a helper function + log.WithFields(f).Debugf("getting repository...") + githubRepository, err := s.repositoryService.GetRepositoryByExternalID(ctx, repositoryID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get installation ID for repository ID: %s", repositoryID) + return "", err + } + // Get github organization + log.WithFields(f).Debugf("getting github organization...") + githubOrg, err := s.githubOrgService.GetGitHubOrganizationByName(ctx, githubRepository.RepositoryOrganizationName) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get github organization for repository ID: %s", repositoryID) + return "", err + } + + installationId = githubOrg.OrganizationInstallationID + if installationId == 0 { + log.WithFields(f).WithError(err).Warnf("unable to get installation ID for repository ID: %s", repositoryID) + return "", err + } + + return fmt.Sprintf("%s/v2/signed/individual/%d/%s/%d", s.ClaV1ApiURL, installationId, repositoryID, pullRequestID), nil + +} + +func (s *service) populateSignURL(ctx context.Context, + latestSignature *v1Models.Signature, callbackURL string, + authorityOrSignatoryName, authorityOrSignatoryEmail string, + sendAsEmail bool, + claManagerName, claManagerEmail string, + defaultValues map[string]interface{}, preferredEmail string) error { + + f := logrus.Fields{ + "functionName": "sign.populateSignURL", + } + log.WithFields(f).Debugf("populating sign url...") + signatureReferenceType := latestSignature.SignatureReferenceType + + log.WithFields(f).Debugf("signatureReferenceType: %s", signatureReferenceType) + log.WithFields(f).Debugf("processing signing request...") + + userSignatureName := Unknown + userSignatureEmail := Unknown + var document v1Models.ClaGroupDocument + var project *v1Models.ClaGroup + var companyModel *v1Models.Company + var err error + var signer DocuSignRecipient + var emailBody string + var emailSubject string + + if signatureReferenceType == utils.SignatureReferenceTypeCompany { + companyModel, err = s.companyRepo.GetCompany(ctx, latestSignature.SignatureReferenceID) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup company by ID: %s", latestSignature.SignatureReferenceID) + return err + } + if companyModel == nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup company by ID: %s", latestSignature.SignatureReferenceID) + return errors.New("no CLA manager lookup error") + } + userSignatureName = claManagerName + userSignatureEmail = claManagerEmail + } else if signatureReferenceType == utils.SignatureReferenceTypeUser { + if !sendAsEmail { + userModel, userErr := s.userService.GetUser(latestSignature.SignatureReferenceID) + if userErr != nil { + log.WithFields(f).WithError(userErr).Warnf("unable to lookup user by ID: %s", latestSignature.SignatureReferenceID) + return userErr + } + log.WithFields(f).Debugf("loaded user : %+v", userModel) + + if userModel == nil { + log.WithFields(f).WithError(userErr).Warnf("unable to lookup user by ID: %s", latestSignature.SignatureReferenceID) + msg := fmt.Sprintf("No user lookup error for user ID: %s", latestSignature.SignatureReferenceID) + return errors.New(msg) + } + + if userModel.Username != "" { + userSignatureName = userModel.Username + } + if getUserEmail(userModel, preferredEmail) != "" { + userSignatureEmail = getUserEmail(userModel, preferredEmail) + } + } + } else { + log.WithFields(f).Warnf("unknown signature reference type: %s", signatureReferenceType) + return errors.New("unknown signature reference type") + } + + // Get the document template to sign + log.WithFields(f).Debugf("getting document template to sign...") + project, err = s.projectRepo.GetCLAGroupByID(ctx, latestSignature.ProjectID, DontLoadRepoDetails) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup project by ID: %s", latestSignature.ProjectID) + return err + } + + if project == nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup project by ID: %s", latestSignature.ProjectID) + return errors.New("no project lookup error") + } + + if signatureReferenceType == utils.SignatureReferenceTypeCompany { + log.WithFields(f).Debugf("loading project corporate document...") + document, err = common.GetCurrentDocument(ctx, project.ProjectCorporateDocuments) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup project corporate document for project: %s", latestSignature.ProjectID) + return err + } + } else { + log.WithFields(f).Debugf("loading project individual document...") + document, err = common.GetCurrentDocument(ctx, project.ProjectIndividualDocuments) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to lookup project individual document for project: %s", latestSignature.ProjectID) + return err + } + } + + // Void the existing envelope to prevent multiple envelopes pending for a signer + envelopeID := latestSignature.SignatureEnvelopeID + if envelopeID != "" { + message := fmt.Sprintf("You are getting this message because your DocuSign Session for project %s expired. A new session will be in place for your signing process.", project.ProjectName) + log.WithFields(f).Debug(message) + err = s.VoidEnvelope(ctx, envelopeID, message) + if err != nil { + log.WithFields(f).WithError(err).Warnf("DocuSign error while voiding the envelope - regardless, continuing on..., error: %s", err) + } + } + + // # Not sure what should be put in as documentId. + // document_id = uuid.uuid4().int & (1 << 16) - 1 # Random 16bit integer -.pylint: disable=no-member + + randomUuid := uuid.Must(uuid.NewV4()).String() + + documentID := int(randomUuid[0])<<8 + int(randomUuid[1]) + log.WithFields(f).Debugf("documentID: %d", documentID) + tab := getTabsFromDocument(&document, strconv.Itoa(documentID), defaultValues) + + // # Create the envelope request object + + if sendAsEmail { + log.WithFields(f).Warnf("assigning signatory name/email: %s/%s", authorityOrSignatoryName, authorityOrSignatoryEmail) + signatoryEmail := authorityOrSignatoryEmail + signatoryName := authorityOrSignatoryName + + var projectName string + var companyName string + + if project != nil { + projectName = project.ProjectName + } + + if companyModel != nil { + companyName = companyModel.CompanyName + } + + pcgs, pcgErr := s.projectClaGroupsRepo.GetProjectsIdsForClaGroup(ctx, project.ProjectID) + if pcgErr != nil { + log.WithFields(f).Debugf("problem fetching project cla groups by id :%s, err: %+v", project.ProjectID, pcgErr) + return pcgErr + } + + if len(pcgs) == 0 { + log.WithFields(f).Debugf("no project cla groups found for project id :%s", project.ProjectID) + return errors.New("no project cla groups found for project id") + } + + var projectNames []string + for _, pcg := range pcgs { + projectNames = append(projectNames, pcg.ProjectName) + } + + if len(projectNames) == 0 { + projectNames = []string{projectName} + } + + claSignatoryParams := &ClaSignatoryEmailParams{ + ClaGroupName: project.ProjectName, + SignatoryName: signatoryName, + CompanyName: companyName, + ProjectNames: projectNames, + ProjectVersion: project.Version, + ClaManagerName: claManagerName, + ClaManagerEmail: claManagerEmail, + } + + log.WithFields(f).Debugf("claSignatoryParams: %+v", claSignatoryParams) + emailSubject, emailBody = claSignatoryEmailContent(*claSignatoryParams) + log.WithFields(f).Debugf("subject: %s, body: %s", emailSubject, emailBody) + + signer = DocuSignRecipient{ + Email: signatoryEmail, + Name: signatoryName, + Tabs: tab, + RecipientId: "1", + RoleName: "signer", + } + + } else { + // This will be the Initial CLA Manager + signatoryName := userSignatureName + signatoryEmail := userSignatureEmail + + // Assigning a clientUserId does not send an email. + // It assumes that the user handles the communication with the client. + // In this case, the user opened the docusign document to manually sign it. + // Thus the email does not need to be sent. + + log.WithFields(f).Debugf("signatoryName: %s, signatoryEmail: %s", signatoryName, signatoryEmail) + + // # Max length for emailSubject is 100 characters - guard/truncate if necessary + emailSubject = fmt.Sprintf("EasyCLA: CLA Signature Request for %s", project.ProjectName) + if len(emailSubject) > 100 { + emailSubject = emailSubject[:97] + "..." + } + + // # Update Signed for label according to signature_type (company or name) + var userIdentifier string + if signatureReferenceType == utils.SignatureReferenceTypeCompany { + userIdentifier = companyModel.CompanyName + } else { + if signatoryName == "Unknown" || signatoryName == "" { + userIdentifier = signatoryEmail + } else { + userIdentifier = signatoryName + } + } + + log.WithFields(f).Debugf("userIdentifier: %s", userIdentifier) + + emailBody = fmt.Sprintf("CLA Sign Request for %s", userIdentifier) + + signer = DocuSignRecipient{ + Email: signatoryEmail, + Name: signatoryName, + Tabs: tab, + RecipientId: "1", + ClientUserId: latestSignature.SignatureID, + RoleName: "signer", + } + } + + contentType := document.DocumentContentType + var pdf []byte + + if document.DocumentS3URL != "" { + log.WithFields(f).Debugf("getting document resource from s3...") + pdf, err = s.getDocumentResource(document.DocumentS3URL) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get document resource from s3 for document: %s", document.DocumentS3URL) + return err + } + } else if strings.HasPrefix(contentType, "url+") { + log.WithFields(f).Debugf("getting document resource from url...") + pdfURL := document.DocumentContent + pdf, err = s.getDocumentResource(pdfURL) + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to get document resource from url: %s", pdfURL) + return err + } + } else { + log.WithFields(f).Debugf("getting document resource from content...") + content := document.DocumentContent + pdf = []byte(content) + } + + documentName := document.DocumentName + log.WithFields(f).Debugf("documentName: %s", documentName) + log.WithFields(f).Debugf("documentID: %d", documentID) + log.WithFields(f).Debugf("contentType: %s", contentType) + + docusignDocument := DocuSignDocument{ + Name: documentName, + DocumentId: strconv.Itoa(documentID), + DocumentBase64: base64.StdEncoding.EncodeToString(pdf), + } + + var envelopeRequest DocuSignEnvelopeRequest + + if callbackURL != "" { + // Webhook properties for callbacks after the user signs the document. + // Ensure that a webhook is returned on the status "Completed" where + // all signers on a document finish signing the document. + recipientEvents := []DocuSignRecipientEvent{ + { + RecipientEventStatusCode: "Completed", + }, + } + + eventNotification := DocuSignEventNotification{ + URL: callbackURL, + LoggingEnabled: true, + RecipientEvents: recipientEvents, + } + + envelopeRequest = DocuSignEnvelopeRequest{ + Documents: []DocuSignDocument{ + docusignDocument, + }, + EmailSubject: emailSubject, + EmailBlurb: emailBody, + EventNotification: eventNotification, + Status: "sent", + Recipients: DocuSignRecipientType{ + Signers: []DocuSignRecipient{ + signer, + }, + }, + } + + } else { + envelopeRequest = DocuSignEnvelopeRequest{ + Documents: []DocuSignDocument{ + docusignDocument, + }, + EmailSubject: emailSubject, + EmailBlurb: emailBody, + Status: "sent", + Recipients: DocuSignRecipientType{ + Signers: []DocuSignRecipient{ + signer, + }, + }, + } + + } + + log.WithFields(f).Debugf("envelopeRequest: %+v", envelopeRequest) + + envelopeResponse, err := s.PrepareSignRequest(ctx, &envelopeRequest) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to create envelope for user: %s", latestSignature.SignatureReferenceID) + return err + } + + if !sendAsEmail { + // The URL the user will be redirected to after signing. + // This route will be in charge of extracting the signature's return_url and redirecting. + recipient := envelopeResponse.Recipients[0] + returnURL := fmt.Sprintf("%s/v2/return-url/%s", s.ClaV1ApiURL, recipient.ClientUserId) + + log.WithFields(f).Debugf("generating signature sign_url, using return-url as: %s", returnURL) + signURL, signErr := s.GetSignURL(envelopeID, recipient.RecipientId, returnURL) + + if signErr != nil { + log.WithFields(f).WithError(err).Warnf("unable to get sign url for user: %s", latestSignature.SignatureReferenceID) + return signErr + } + + log.WithFields(f).Debugf("setting signature sign_url as: %s", signURL) + latestSignature.SignatureSignURL = signURL + } + + // Save Envelope ID in signature. + log.WithFields(f).Debugf("saving signature to database...") + latestSignature.SignatureEnvelopeID = envelopeResponse.EnvelopeId + + latestSignature, err = s.signatureService.CreateOrUpdateSignature(ctx, latestSignature) + + if err != nil { + log.WithFields(f).WithError(err).Warnf("unable to save signature to database for user: %s", latestSignature.SignatureReferenceID) + return err + } + + log.WithFields(f).Debugf("saved signature to database - id: %s", latestSignature.SignatureID) + log.WithFields(f).Debugf("populate_sign_url - complete") + + return nil +} + +func (s *service) getDocumentResource(urlString string) ([]byte, error) { + + // validate the URL + u, err := url.ParseRequestURI(urlString) + if err != nil { + return nil, err + } + + resp, err := http.Get(u.String()) if err != nil { - log.WithFields(f).WithError(err).Warn("unable to get access token") return nil, err } - log.WithFields(f).Debugf("access token: %s", accessToken) + + defer func() { + if err := resp.Body.Close(); err != nil { + log.Warnf("error closing response body: %v", err) + } + }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to get document resource from url: %s, status code: %d", urlString, resp.StatusCode) + } + + return io.ReadAll(resp.Body) +} + +// Helper function to extract the docusign tabs from the document +func getTabsFromDocument(document *v1Models.ClaGroupDocument, documentID string, defaultValues map[string]interface{}) DocuSignTab { + var docTab DocuSignTab + f := logrus.Fields{ + "functionName": "sign.getTabsFromDocument", + "documentID": documentID, + } + log.WithFields(f).Debugf("getting tabs from document...") + for _, tab := range document.DocumentTabs { + var args DocuSignTabDetails + args.DocumentId = documentID + args.PageNumber = strconv.FormatInt(tab.DocumentTabPage, 10) + args.XPosition = strconv.FormatInt(tab.DocumentTabPositionx, 10) + args.YPosition = strconv.FormatInt(tab.DocumentTabPositiony, 10) + args.Width = strconv.FormatInt(tab.DocumentTabWidth, 10) + args.Height = strconv.FormatInt(tab.DocumentTabHeight, 10) + args.CustomTabId = tab.DocumentTabID + args.TabLabel = tab.DocumentTabID + args.Name = tab.DocumentTabName + + if tab.DocumentTabAnchorString != "" { + args.AnchorString = tab.DocumentTabAnchorString + args.AnchorIgnoreIfNotPresent = strconv.FormatBool(tab.DocumentTabAnchorIgnoreIfNotPresent) + args.AnchorXOffset = strconv.FormatInt(tab.DocumentTabAnchorxOffset, 10) + args.AnchorYOffset = strconv.FormatInt(tab.DocumentTabAnchoryOffset, 10) + } + + if defaultValues != nil { + if value, ok := defaultValues[tab.DocumentTabID].(string); ok { + args.Value = value + } + } + + switch tab.DocumentTabType { + case "text": + docTab.TextTabs = append(docTab.TextTabs, args) + case "text_unlocked": + args.Locked = DocSignFalse + docTab.TextTabs = append(docTab.TextTabs, args) + case "text_optional": + args.Required = DocSignFalse + docTab.TextOptionalTabs = append(docTab.TextOptionalTabs, args) + case "number": + docTab.NumberTabs = append(docTab.NumberTabs, args) + case "sign": + docTab.SignHereTabs = append(docTab.SignHereTabs, args) + case "sign_optional": + args.Optional = "true" + docTab.SignHereOptionalTabs = append(docTab.SignHereOptionalTabs, args) + case "date": + docTab.DateSignedTabs = append(docTab.DateSignedTabs, args) + default: + log.WithFields(f).Warnf("unknown document tab type: %s", tab.DocumentTabType) + continue + } + } + + return docTab +} + +// helper function to get user email +func getUserEmail(user *v1Models.User, preferredEmail string) string { + if preferredEmail != "" { + if utils.StringInSlice(preferredEmail, user.Emails) || user.LfEmail == strfmt.Email(preferredEmail) { + return preferredEmail + } + } + if user.LfEmail != "" { + return string(user.LfEmail) + } + if len(user.Emails) > 0 { + return user.Emails[0] + } + return "" +} + +func getActiveSignatureReturnURL(userID string, metadata map[string]interface{}) (string, error) { + + f := logrus.Fields{ + "functionName": "sign.getActiveSignatureReturnURL", + } + + var returnURL string + var err error + var pullRequestID int + var installationID int64 + var repositoryID int64 + + if found, ok := metadata["pull_request_id"].(int); ok { + pullRequestID = found + } else { + log.WithFields(f).WithError(err).Warnf("unable to get pull request ID for user: %s", userID) + return "", err + } + + if found, ok := metadata["installation_id"].(int64); ok { + installationID = found + } else { + log.WithFields(f).WithError(err).Warnf("unable to get installation ID for user: %s", userID) + return "", err + } + + if found, ok := metadata["repository_id"].(int64); ok { + repositoryID = found + } else { + log.WithFields(f).WithError(err).Warnf("unable to get repository ID for user: %s", userID) + return "", err + } + + returnURL, err = github.GetReturnURL(context.Background(), installationID, repositoryID, pullRequestID) + + if err != nil { + return "", err + } + + return returnURL, nil + +} + +func (s *service) createDefaultIndividualValues(user *v1Models.User, preferredEmail string) map[string]interface{} { + f := logrus.Fields{ + "functionName": "sign.createDefaultIndiviualValues", + } + log.WithFields(f).Debugf("creating individual default values...") + + defaultValues := make(map[string]interface{}) + + if user != nil { + if user.Username != "" { + defaultValues["user_name"] = user.Username + defaultValues["public_name"] = user.Username + } + } + + if preferredEmail != "" { + if utils.StringInSlice(preferredEmail, user.Emails) || user.LfEmail == strfmt.Email(preferredEmail) { + defaultValues["user_email"] = preferredEmail + } + } + + return defaultValues +} + +func getLatestSignature(signatures []*v1Models.Signature) *v1Models.Signature { + var latestSignature *v1Models.Signature + for _, signature := range signatures { + if latestSignature == nil { + latestSignature = signature + } else { + if signature.SignatureMajorVersion > latestSignature.SignatureMajorVersion { + latestSignature = signature + } else if signature.SignatureMajorVersion == latestSignature.SignatureMajorVersion { + if signature.SignatureMinorVersion > latestSignature.SignatureMinorVersion { + latestSignature = signature + } + } + } + } + return latestSignature +} + +func (s *service) RequestIndividualSignatureGerrit(ctx context.Context, input *models.IndividualSignatureInput) (*models.IndividualSignatureOutput, error) { return nil, nil } @@ -491,3 +1351,16 @@ func prepareUserForSigning(ctx context.Context, userEmail string, companySFID, p return nil } + +func claSignatoryEmailContent(params ClaSignatoryEmailParams) (string, string) { + projectNamesList := strings.Join(params.ProjectNames, ", ") + + emailSubject := fmt.Sprintf("EasyCLA: CLA Signature Request for %s", params.ClaGroupName) + emailBody := fmt.Sprintf("

Hello %s,

", params.SignatoryName) + emailBody += fmt.Sprintf("

This is a notification email from EasyCLA regarding the project(s) %s associated with the CLA Group %s. %s has designated you as an authorized signatory for the organization %s. In order for employees of your company to contribute to any of the above project(s), they must do so under a Contributor License Agreement signed by someone with authority on behalf of your company.

", projectNamesList, params.ClaGroupName, params.ClaManagerName, params.CompanyName) + emailBody += fmt.Sprintf("

After you sign, %s (as the initial CLA Manager for your company) will be able to maintain the list of specific employees authorized to contribute to the project(s) under this signed CLA.

", params.ClaManagerName) + emailBody += fmt.Sprintf("

If you are authorized to sign on your company’s behalf, and if you approve %s as your initial CLA Manager, please review the document and sign the CLA. If you have questions, or if you are not an authorized signatory of this company, please contact the requester at %s.

", params.ClaManagerName, params.ClaManagerEmail) + // You would need to implement the appendEmailHelpSignOffContent function in Go separately + + return emailSubject, emailBody +} diff --git a/cla-backend-go/v2/store/repository.go b/cla-backend-go/v2/store/repository.go index 1d58861d6..55f06dcd0 100644 --- a/cla-backend-go/v2/store/repository.go +++ b/cla-backend-go/v2/store/repository.go @@ -5,6 +5,7 @@ package store import ( "context" + "encoding/json" "fmt" "github.com/sirupsen/logrus" @@ -27,6 +28,7 @@ type DBStore struct { // Repository interface type Repository interface { SetActiveSignatureMetaData(ctx context.Context, key string, expire int64, value string) error + GetActiveSignatureMetaData(ctx context.Context, UserId string) (map[string]interface{}, error) } type repo struct { @@ -44,6 +46,57 @@ func NewRepository(awsSession *session.Session, stage string) Repository { } } +// GetActiveSignatureMetaData returns active signature meta data +func (r repo) GetActiveSignatureMetaData(ctx context.Context, userId string) (map[string]interface{}, error) { + f := logrus.Fields{ + "functionName": "v2.store.repository.GetActiveSignatureMetaData", + utils.XREQUESTID: ctx.Value(utils.XREQUESTID), + "userId": userId, + } + var metadata map[string]interface{} + + log.WithFields(f).Debugf("querying for user: %s", userId) + + key := fmt.Sprintf("active_signature:%s", userId) + + result, err := r.dynamoDBClient.GetItem(&dynamodb.GetItemInput{ + TableName: &r.storeTableName, + Key: map[string]*dynamodb.AttributeValue{ + "key": { + S: &key, + }, + }, + }) + + if err != nil { + log.WithFields(f).WithError(err).Warn("problem querying store table") + return metadata, err + } + + if result.Item == nil { + log.WithFields(f).Warn("no record found") + return metadata, nil + } + + var store DBStore + + err = dynamodbattribute.UnmarshalMap(result.Item, &store) + if err != nil { + log.WithFields(f).WithError(err).Warn("problem unmarshalling store record") + return metadata, err + } + + log.WithFields(f).Debugf("Signature meta record data found: %+v ", store) + + err = json.Unmarshal([]byte(store.Value), &metadata) + if err != nil { + log.WithFields(f).WithError(err).Warn("problem unmarshalling store record") + return metadata, err + } + + return metadata, nil +} + // SetActiveSignatureMetaData sets active signature meta data func (r repo) SetActiveSignatureMetaData(ctx context.Context, key string, expire int64, value string) error { f := logrus.Fields{ diff --git a/cla-backend/cla/models/docusign_models.py b/cla-backend/cla/models/docusign_models.py index 096b645ef..45a64e9df 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 sign again. + # signed the most recent major version, they do not need to fsign 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))