From f07cf45e7d0eec19132f8d8e358eda211ddce93d Mon Sep 17 00:00:00 2001 From: Vincent Xiao Date: Wed, 23 Sep 2020 10:38:20 -0700 Subject: [PATCH] Add http endpoint to soft delete a document --- api/client.yaml | 38 ++++++++++++++ internal/database/mysql.go | 4 ++ internal/database/sqlite.go | 2 +- pkg/client/README.md | 1 + pkg/client/api/openapi.yaml | 44 ++++++++++++++++ pkg/client/api_customers.go | 89 +++++++++++++++++++++++++++++++ pkg/client/docs/CustomersApi.md | 48 +++++++++++++++++ pkg/customers/documents.go | 40 +++++++++++++- pkg/customers/documents_test.go | 92 +++++++++++++++++++++++++++++++++ 9 files changed, 356 insertions(+), 2 deletions(-) diff --git a/api/client.yaml b/api/client.yaml index a1710ac9b..2b1b2e576 100644 --- a/api/client.yaml +++ b/api/client.yaml @@ -1203,6 +1203,44 @@ paths: application/json: schema: $ref: 'https://raw.githubusercontent.com/moov-io/api/master/openapi-common.yaml#/components/schemas/Error' + delete: + description: Remove a customer's document + operationId: deleteCustomerDocument + parameters: + - name: X-Request-ID + in: header + description: Optional requestID allows application developer to trace requests through the systems logs + example: rs4f9915 + schema: + type: string + - name: customerID + in: path + description: ID of the customer that owns the document + required: true + schema: + example: e210a9d6-d755-4455-9bd2-9577ea7e1081 + type: string + style: simple + - name: documentID + in: path + description: ID of the document + required: true + schema: + example: 9577ea7e1081 + type: string + style: simple + responses: + "204": + description: Customer's document successfully deleted + '400': + description: Failed to delete customer's document, see error(s) + content: + application/json: + schema: + $ref: 'https://raw.githubusercontent.com/moov-io/api/master/openapi-common.yaml#/components/schemas/Error' + summary: Delete a customer's document + tags: + - Customers /customers/{customerID}/ofac: get: tags: [Customers] diff --git a/internal/database/mysql.go b/internal/database/mysql.go index dd32c52e7..075b1f9da 100644 --- a/internal/database/mysql.go +++ b/internal/database/mysql.go @@ -128,6 +128,10 @@ var ( "create_namespace_configuration", `create table namespace_configuration(namespace varchar(40) primary key not null, legal_entity varchar(40) not null, primary_account varchar(40) not null);`, ), + execsql( + "add_deleted_at__to__documents", + "alter table documents add column deleted_at datetime;", + ), ) ) diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go index a8d4d5fa3..beaaa6278 100644 --- a/internal/database/sqlite.go +++ b/internal/database/sqlite.go @@ -61,7 +61,7 @@ var ( ), execsql( "create_documents", - `create table if not exists documents(document_id primary key, customer_id, type, content_type, uploaded_at datetime);`, + `create table if not exists documents(document_id primary key, customer_id, type, content_type, uploaded_at datetime, deleted_at datetime);`, ), execsql( "create_disclaimers", diff --git a/pkg/client/README.md b/pkg/client/README.md index 1dc2153b5..71d761232 100644 --- a/pkg/client/README.md +++ b/pkg/client/README.md @@ -48,6 +48,7 @@ Class | Method | HTTP request | Description *CustomersApi* | [**DecryptAccountNumber**](docs/CustomersApi.md#decryptaccountnumber) | **Post** /customers/{customerID}/accounts/{accountID}/decrypt | Decrypt Account Number *CustomersApi* | [**DeleteCustomer**](docs/CustomersApi.md#deletecustomer) | **Delete** /customers/{customerID} | Delete Customer by ID *CustomersApi* | [**DeleteCustomerAccount**](docs/CustomersApi.md#deletecustomeraccount) | **Delete** /customers/{customerID}/accounts | Delete Customer Account +*CustomersApi* | [**DeleteCustomerDocument**](docs/CustomersApi.md#deletecustomerdocument) | **Delete** /customers/{customerID}/documents/{documentID} | Delete a customer's document *CustomersApi* | [**GetAccountValidation**](docs/CustomersApi.md#getaccountvalidation) | **Get** /customers/{customerID}/accounts/{accountID}/validations/{validationID} | Get Account Validation *CustomersApi* | [**GetCustomer**](docs/CustomersApi.md#getcustomer) | **Get** /customers/{customerID} | Retrieve customer *CustomersApi* | [**GetCustomerAccountByID**](docs/CustomersApi.md#getcustomeraccountbyid) | **Get** /customers/{customerID}/accounts/{accountID} | Get Customer Account by ID diff --git a/pkg/client/api/openapi.yaml b/pkg/client/api/openapi.yaml index 7fbcdc26a..3098fd8e9 100644 --- a/pkg/client/api/openapi.yaml +++ b/pkg/client/api/openapi.yaml @@ -1398,6 +1398,50 @@ paths: tags: - Customers /customers/{customerID}/documents/{documentID}: + delete: + description: Remove a customer's document + operationId: deleteCustomerDocument + parameters: + - description: Optional requestID allows application developer to trace requests + through the systems logs + example: rs4f9915 + explode: false + in: header + name: X-Request-ID + required: false + schema: + type: string + style: simple + - description: ID of the customer that owns the document + explode: false + in: path + name: customerID + required: true + schema: + example: e210a9d6-d755-4455-9bd2-9577ea7e1081 + type: string + style: simple + - description: ID of the document + explode: false + in: path + name: documentID + required: true + schema: + example: 9577ea7e1081 + type: string + style: simple + responses: + "204": + description: Customer's document successfully deleted + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + description: Failed to delete customer's document, see error(s) + summary: Delete a customer's document + tags: + - Customers get: description: Retrieve the referenced document operationId: getCustomerDocumentContents diff --git a/pkg/client/api_customers.go b/pkg/client/api_customers.go index f43d9c9b0..ce61b46f3 100644 --- a/pkg/client/api_customers.go +++ b/pkg/client/api_customers.go @@ -839,6 +839,95 @@ func (a *CustomersApiService) DeleteCustomerAccount(ctx _context.Context, custom return localVarHTTPResponse, nil } +// DeleteCustomerDocumentOpts Optional parameters for the method 'DeleteCustomerDocument' +type DeleteCustomerDocumentOpts struct { + XRequestID optional.String +} + +/* +DeleteCustomerDocument Delete a customer's document +Remove a customer's document + * @param ctx _context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + * @param customerID ID of the customer that owns the document + * @param documentID ID of the document + * @param optional nil or *DeleteCustomerDocumentOpts - Optional Parameters: + * @param "XRequestID" (optional.String) - Optional requestID allows application developer to trace requests through the systems logs +*/ +func (a *CustomersApiService) DeleteCustomerDocument(ctx _context.Context, customerID string, documentID string, localVarOptionals *DeleteCustomerDocumentOpts) (*_nethttp.Response, error) { + var ( + localVarHTTPMethod = _nethttp.MethodDelete + localVarPostBody interface{} + localVarFormFileName string + localVarFileName string + localVarFileBytes []byte + ) + + // create path and map variables + localVarPath := a.client.cfg.BasePath + "/customers/{customerID}/documents/{documentID}" + localVarPath = strings.Replace(localVarPath, "{"+"customerID"+"}", _neturl.QueryEscape(parameterToString(customerID, "")), -1) + + localVarPath = strings.Replace(localVarPath, "{"+"documentID"+"}", _neturl.QueryEscape(parameterToString(documentID, "")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := _neturl.Values{} + localVarFormParams := _neturl.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + if localVarOptionals != nil && localVarOptionals.XRequestID.IsSet() { + localVarHeaderParams["X-Request-ID"] = parameterToString(localVarOptionals.XRequestID.Value(), "") + } + r, err := a.client.prepareRequest(ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, localVarFormFileName, localVarFileName, localVarFileBytes) + if err != nil { + return nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(r) + if err != nil || localVarHTTPResponse == nil { + return localVarHTTPResponse, err + } + + localVarBody, err := _ioutil.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + if err != nil { + return localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 400 { + var v Error + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarHTTPResponse, newErr + } + newErr.model = v + } + return localVarHTTPResponse, newErr + } + + return localVarHTTPResponse, nil +} + // GetAccountValidationOpts Optional parameters for the method 'GetAccountValidation' type GetAccountValidationOpts struct { XRequestID optional.String diff --git a/pkg/client/docs/CustomersApi.md b/pkg/client/docs/CustomersApi.md index 00def7263..74c29930d 100644 --- a/pkg/client/docs/CustomersApi.md +++ b/pkg/client/docs/CustomersApi.md @@ -12,6 +12,7 @@ Method | HTTP request | Description [**DecryptAccountNumber**](CustomersApi.md#DecryptAccountNumber) | **Post** /customers/{customerID}/accounts/{accountID}/decrypt | Decrypt Account Number [**DeleteCustomer**](CustomersApi.md#DeleteCustomer) | **Delete** /customers/{customerID} | Delete Customer by ID [**DeleteCustomerAccount**](CustomersApi.md#DeleteCustomerAccount) | **Delete** /customers/{customerID}/accounts | Delete Customer Account +[**DeleteCustomerDocument**](CustomersApi.md#DeleteCustomerDocument) | **Delete** /customers/{customerID}/documents/{documentID} | Delete a customer's document [**GetAccountValidation**](CustomersApi.md#GetAccountValidation) | **Get** /customers/{customerID}/accounts/{accountID}/validations/{validationID} | Get Account Validation [**GetCustomer**](CustomersApi.md#GetCustomer) | **Get** /customers/{customerID} | Retrieve customer [**GetCustomerAccountByID**](CustomersApi.md#GetCustomerAccountByID) | **Get** /customers/{customerID}/accounts/{accountID} | Get Customer Account by ID @@ -413,6 +414,53 @@ No authorization required [[Back to README]](../README.md) +## DeleteCustomerDocument + +> DeleteCustomerDocument(ctx, customerID, documentID, optional) + +Delete a customer's document + +Remove a customer's document + +### Required Parameters + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- +**ctx** | **context.Context** | context for authentication, logging, cancellation, deadlines, tracing, etc. +**customerID** | **string**| ID of the customer that owns the document | +**documentID** | **string**| ID of the document | + **optional** | ***DeleteCustomerDocumentOpts** | optional parameters | nil if no parameters + +### Optional Parameters + +Optional parameters are passed through a pointer to a DeleteCustomerDocumentOpts struct + + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + + + **xRequestID** | **optional.String**| Optional requestID allows application developer to trace requests through the systems logs | + +### Return type + + (empty response body) + +### Authorization + +No authorization required + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json + +[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) +[[Back to Model list]](../README.md#documentation-for-models) +[[Back to README]](../README.md) + + ## GetAccountValidation > AccountValidationResponse GetAccountValidation(ctx, customerID, accountID, validationID, optional) diff --git a/pkg/customers/documents.go b/pkg/customers/documents.go index c9a0e6090..cfa449097 100644 --- a/pkg/customers/documents.go +++ b/pkg/customers/documents.go @@ -18,6 +18,7 @@ import ( "github.com/moov-io/base" moovhttp "github.com/moov-io/base/http" + "github.com/moov-io/customers/pkg/client" "github.com/moov-io/customers/pkg/route" @@ -36,6 +37,7 @@ func AddDocumentRoutes(logger log.Logger, r *mux.Router, repo DocumentRepository r.Methods("GET").Path("/customers/{customerID}/documents").HandlerFunc(getCustomerDocuments(logger, repo)) r.Methods("POST").Path("/customers/{customerID}/documents").HandlerFunc(uploadCustomerDocument(logger, repo, bucketFactory)) r.Methods("GET").Path("/customers/{customerID}/documents/{documentId}").HandlerFunc(retrieveRawDocument(logger, repo, bucketFactory)) + r.Methods("DELETE").Path("/customers/{customerID}/documents/{documentId}").HandlerFunc(deleteCustomerDocument(logger, repo)) } func getDocumentID(w http.ResponseWriter, r *http.Request) string { @@ -198,6 +200,29 @@ func retrieveRawDocument(logger log.Logger, repo DocumentRepository, bucketFacto } } +func deleteCustomerDocument(logger log.Logger, repo DocumentRepository) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w = route.Responder(logger, w, r) + requestID := moovhttp.GetRequestID(r) + + customerID, documentID := route.GetCustomerID(w, r), getDocumentID(w, r) + if customerID == "" || documentID == "" { + return + } + + err := repo.deleteCustomerDocument(customerID, documentID) + if err != nil { + moovhttp.Problem(w, fmt.Errorf("deleting document: %v", err)) + logger.Log("documents", fmt.Sprintf("error deleting document=%s for customer=%s: %v", documentID, customerID, err), "requestID", requestID) + return + } + + logger.Log("documents", fmt.Sprintf("successfully deleted document=%s for customer=%s", documentID, customerID), "requestID", requestID) + + w.WriteHeader(http.StatusNoContent) + } +} + func makeDocumentKey(customerID, documentId string) string { return fmt.Sprintf("customer-%s-document-%s", customerID, documentId) } @@ -205,6 +230,7 @@ func makeDocumentKey(customerID, documentId string) string { type DocumentRepository interface { getCustomerDocuments(customerID string) ([]*client.Document, error) writeCustomerDocument(customerID string, doc *client.Document) error + deleteCustomerDocument(customerID string, documentID string) error } type sqlDocumentRepository struct { @@ -219,7 +245,7 @@ func NewDocumentRepo(logger log.Logger, db *sql.DB) DocumentRepository { } } func (r *sqlDocumentRepository) getCustomerDocuments(customerID string) ([]*client.Document, error) { - query := `select document_id, type, content_type, uploaded_at from documents where customer_id = ?` + query := `select document_id, type, content_type, uploaded_at from documents where customer_id = ? and deleted_at is null` stmt, err := r.db.Prepare(query) if err != nil { return nil, fmt.Errorf("getCustomerDocuments: prepare %v", err) @@ -256,3 +282,15 @@ func (r *sqlDocumentRepository) writeCustomerDocument(customerID string, doc *cl } return nil } + +func (r *sqlDocumentRepository) deleteCustomerDocument(customerID string, documentID string) error { + query := `update documents set deleted_at = ? where customer_id = ? and document_id = ? and deleted_at is null;` + stmt, err := r.db.Prepare(query) + if err != nil { + return err + } + defer stmt.Close() + + _, err = stmt.Exec(time.Now(), customerID, documentID) + return err +} diff --git a/pkg/customers/documents_test.go b/pkg/customers/documents_test.go index 2af1ab5f0..0475e24c4 100644 --- a/pkg/customers/documents_test.go +++ b/pkg/customers/documents_test.go @@ -18,8 +18,11 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/moov-io/base" + "github.com/stretchr/testify/require" + "github.com/moov-io/customers/internal/database" "github.com/moov-io/customers/pkg/client" @@ -46,6 +49,11 @@ func (r *testDocumentRepository) writeCustomerDocument(customerID string, doc *c return r.err } +func (r *testDocumentRepository) deleteCustomerDocument(customerID string, documentID string) error { + r.written = nil + return r.err +} + func TestDocuments__getDocumentID(t *testing.T) { w := httptest.NewRecorder() req := httptest.NewRequest("GET", "/ping", nil) @@ -181,6 +189,35 @@ func TestDocumentsUploadAndRetrieval(t *testing.T) { } } +func TestDocuments__delete(t *testing.T) { + db := database.CreateTestSqliteDB(t) + repo := &sqlDocumentRepository{db.DB, log.NewNopLogger()} + + router := mux.NewRouter() + AddDocumentRoutes(log.NewNopLogger(), router, repo, testBucket) + + customerID := base.ID() + // create document + doc := &client.Document{ + DocumentID: base.ID(), + Type: "DriversLicense", + ContentType: "image/png", + UploadedAt: time.Now(), + } + err := repo.writeCustomerDocument(customerID, doc) + require.NoError(t, err) + + w := httptest.NewRecorder() + req := httptest.NewRequest("DELETE", fmt.Sprintf("/customers/%s/documents/%s", customerID, doc.DocumentID), nil) + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNoContent, w.Code) + + docs, err := repo.getCustomerDocuments(customerID) + require.NoError(t, err) + require.Empty(t, docs) +} + func TestDocuments__uploadCustomerDocument(t *testing.T) { repo := &testDocumentRepository{} @@ -253,3 +290,58 @@ func TestDocumentRepository(t *testing.T) { defer mysqlDB.Close() check(t, &sqlDocumentRepository{mysqlDB.DB, log.NewNopLogger()}) } + +func TestDocumentsRepository__Delete(t *testing.T) { + db := database.CreateTestSqliteDB(t) + repo := &sqlDocumentRepository{db.DB, log.NewNopLogger()} + + type document struct { + *client.Document + deleted bool + } + + customerID := base.ID() + docs := make([]*document, 10) + for i := 0; i < len(docs); i++ { + doc := &client.Document{ + DocumentID: base.ID(), + Type: "DriversLicense", + ContentType: "image/png", + } + err := repo.writeCustomerDocument(customerID, doc) + require.NoError(t, err) + docs[i] = &document{ + Document: doc, + } + } + + // mark documents to be deleted + indexesToDelete := []int{1, 2, 5, 8} + for _, idx := range indexesToDelete { + require.Less(t, idx, len(docs)) + docs[idx].deleted = true + require.NoError(t, + repo.deleteCustomerDocument(customerID, docs[idx].DocumentID), + ) + } + + deletedDocIDs := make(map[string]bool) + // query all documents that have been marked as deleted + query := `select document_id from documents where deleted_at is not null;` + stmt, err := repo.db.Prepare(query) + require.NoError(t, err) + + rows, err := stmt.Query() + require.NoError(t, err) + + for rows.Next() { + var ID *string + require.NoError(t, rows.Scan(&ID)) + deletedDocIDs[*ID] = true + } + + for _, doc := range docs { + _, ok := deletedDocIDs[doc.DocumentID] + require.Equal(t, doc.deleted, ok) + } +}