Repository: moov-io/customers
Pull request #42 (2019-10-15 19:25:12)

cmd/server: add routes and persistence for disclaimers
Issue: https://github.com/moov-io/customers/issues/32


Patch:
diff --git a/client/README.md b/client/README.md
index 67e1621..c737723 100644
--- a/client/README.md
+++ b/client/README.md
@@ -52,6 +52,7 @@ Class | Method | HTTP request | Description
  - [CreatePhone](docs/CreatePhone.md)
  - [Customer](docs/Customer.md)
  - [CustomerMetadata](docs/CustomerMetadata.md)
+ - [Disclaimer](docs/Disclaimer.md)
  - [Document](docs/Document.md)
  - [Error](docs/Error.md)
  - [Phone](docs/Phone.md)
diff --git a/client/api/openapi.yaml b/client/api/openapi.yaml
index 70b221c..e9552f5 100644
--- a/client/api/openapi.yaml
+++ b/client/api/openapi.yaml
@@ -829,6 +829,31 @@ components:
           description: Address is currently being used for customer
           type: boolean
       type: object
+    Disclaimer:
+      properties:
+        ID:
+          description: The unique identifier for this Disclaimer
+          example: 9342f3a7
+          type: string
+        text:
+          description: Text that user must accept
+          example: Set of conditions
+          type: string
+        documentID:
+          description: Optional ID which references a Document included in the disclaimer
+          example: 08a184e1
+          type: string
+        acceptedAt:
+          description: Timestamp if disclaimer has been accepted, a timestamp before
+            the year 2000 indicates no acceptance.
+          example: 2016-08-29T09:12:33.001Z
+          format: date-time
+          type: string
+      type: object
+    Disclaimers:
+      items:
+        $ref: '#/components/schemas/Disclaimer'
+      type: array
     Document:
       example:
         uploadedAt: 2016-08-29T09:12:33.001Z
diff --git a/client/docs/Disclaimer.md b/client/docs/Disclaimer.md
new file mode 100644
index 0000000..29c6320
--- /dev/null
+++ b/client/docs/Disclaimer.md
@@ -0,0 +1,14 @@
+# Disclaimer
+
+## Properties
+
+Name | Type | Description | Notes
+------------ | ------------- | ------------- | -------------
+**ID** | **string** | The unique identifier for this Disclaimer | [optional] 
+**Text** | **string** | Text that user must accept | [optional] 
+**DocumentID** | **string** | Optional ID which references a Document included in the disclaimer | [optional] 
+**AcceptedAt** | [**time.Time**](time.Time.md) | Timestamp if disclaimer has been accepted, a timestamp before the year 2000 indicates no acceptance. | [optional] 
+
+[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
+
+
diff --git a/client/model_disclaimer.go b/client/model_disclaimer.go
new file mode 100644
index 0000000..e19e39c
--- /dev/null
+++ b/client/model_disclaimer.go
@@ -0,0 +1,26 @@
+/*
+ * Customers API
+ *
+ * Customers ...
+ *
+ * API version: v1
+ * Generated by: OpenAPI Generator (https://openapi-generator.tech)
+ */
+
+package openapi
+
+import (
+	"time"
+)
+
+// Disclaimer struct for Disclaimer
+type Disclaimer struct {
+	// The unique identifier for this Disclaimer
+	ID string `json:"ID,omitempty"`
+	// Text that user must accept
+	Text string `json:"text,omitempty"`
+	// Optional ID which references a Document included in the disclaimer
+	DocumentID string `json:"documentID,omitempty"`
+	// Timestamp if disclaimer has been accepted, a timestamp before the year 2000 indicates no acceptance.
+	AcceptedAt time.Time `json:"acceptedAt,omitempty"`
+}
diff --git a/cmd/server/disclaimers.go b/cmd/server/disclaimers.go
new file mode 100644
index 0000000..b17d0e7
--- /dev/null
+++ b/cmd/server/disclaimers.go
@@ -0,0 +1,177 @@
+// Copyright 2019 The Moov Authors
+// Use of this source code is governed by an Apache License
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"database/sql"
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"time"
+
+	"github.com/moov-io/base"
+	moovhttp "github.com/moov-io/base/http"
+	client "github.com/moov-io/customers/client"
+
+	"github.com/go-kit/kit/log"
+	"github.com/gorilla/mux"
+)
+
+var (
+	errNoDisclaimerID = errors.New("no Disclaimer ID found")
+)
+
+func addDisclaimerRoute(logger log.Logger, r *mux.Router, repo disclaimerRepository) {
+	r.Methods("GET").Path("/customers/{customerID}/disclaimers").HandlerFunc(getCustomerDisclaimers(logger, repo))
+	r.Methods("POST").Path("/customers/{customerID}/disclaimers/{disclaimerID}").HandlerFunc(acceptDisclaimer(logger, repo))
+}
+
+func getDisclaimerID(w http.ResponseWriter, r *http.Request) string {
+	v, ok := mux.Vars(r)["disclaimerID"]
+	if !ok || v == "" {
+		moovhttp.Problem(w, errNoDisclaimerID)
+		return ""
+	}
+	return v
+}
+
+func getCustomerDisclaimers(logger log.Logger, repo disclaimerRepository) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w = wrapResponseWriter(logger, w, r)
+
+		customerID := getCustomerID(w, r)
+		if customerID == "" {
+			return
+		}
+
+		disclaimers, err := repo.getCustomerDisclaimers(customerID)
+		if err != nil {
+			moovhttp.Problem(w, err)
+			return
+		}
+
+		w.Header().Set("Content-Type", "application/json; charset=utf-8")
+		w.WriteHeader(http.StatusOK)
+		json.NewEncoder(w).Encode(disclaimers)
+	}
+}
+
+func acceptDisclaimer(logger log.Logger, repo disclaimerRepository) http.HandlerFunc {
+	return func(w http.ResponseWriter, r *http.Request) {
+		w = wrapResponseWriter(logger, w, r)
+
+		customerID, disclaimerID := getCustomerID(w, r), getDisclaimerID(w, r)
+		if customerID == "" || disclaimerID == "" {
+			return
+		}
+
+		if err := repo.acceptDisclaimer(customerID, disclaimerID); err != nil {
+			moovhttp.Problem(w, err)
+			return
+		}
+		w.WriteHeader(http.StatusOK)
+	}
+}
+
+type disclaimerRepository interface {
+	getCustomerDisclaimers(customerID string) ([]*client.Disclaimer, error)
+	acceptDisclaimer(customerID, disclaimerID string) error
+}
+
+type sqlDisclaimerRepository struct {
+	db     *sql.DB
+	logger log.Logger
+}
+
+func (r *sqlDisclaimerRepository) close() error {
+	return r.db.Close()
+}
+
+func (r *sqlDisclaimerRepository) getCustomerDisclaimers(customerID string) ([]*client.Disclaimer, error) {
+	query := `select d.disclaimer_id, d.text, d.document_id, da.accepted_at from disclaimers as d
+left outer join disclaimer_acceptances as da on d.disclaimer_id = da.disclaimer_id
+where d.deleted_at is null;`
+	stmt, err := r.db.Prepare(query)
+	if err != nil {
+		return nil, err
+	}
+	defer stmt.Close()
+
+	rows, err := stmt.Query()
+	if err != nil {
+		return nil, err
+	}
+	defer rows.Close()
+
+	var out []*client.Disclaimer
+	for rows.Next() {
+		disc := &client.Disclaimer{}
+		var acceptedAt *time.Time
+		if err := rows.Scan(&disc.ID, &disc.Text, &disc.DocumentID, &acceptedAt); err != nil {
+			return nil, err
+		}
+		if acceptedAt != nil && !acceptedAt.IsZero() {
+			disc.AcceptedAt = *acceptedAt
+		}
+		out = append(out, disc)
+	}
+	return out, rows.Err()
+}
+
+func (r *sqlDisclaimerRepository) acceptDisclaimer(customerID, disclaimerID string) error {
+	tx, err := r.db.Begin()
+	if err != nil {
+		return err
+	}
+
+	query := `select disclaimer_id from disclaimers where disclaimer_id = ? and deleted_at is null limit 1;`
+	stmt, err := r.db.Prepare(query)
+	if err != nil {
+		tx.Rollback()
+		return err
+	}
+
+	row := stmt.QueryRow(disclaimerID)
+	var discID string
+	if err := row.Scan(&discID); discID != disclaimerID || err != nil {
+		stmt.Close()
+		return fmt.Errorf("acceptDisclaimer: missing disclaimer: %v rollback=%v", err, tx.Rollback())
+	}
+	stmt.Close()
+
+	// write the acceptance row now
+	query = `insert into disclaimer_acceptances (disclaimer_id, customer_id, accepted_at) values (?, ?, ?);`
+	stmt, err = tx.Prepare(query)
+	if err != nil {
+		tx.Rollback()
+		return err
+	}
+	defer stmt.Close()
+
+	_, err = stmt.Exec(disclaimerID, customerID, time.Now())
+	if err != nil {
+		tx.Rollback()
+		return err
+	}
+	return tx.Commit()
+}
+
+func (r *sqlDisclaimerRepository) insertDisclaimer(text, documentID string) (*client.Disclaimer, error) {
+	query := `insert into disclaimers (disclaimer_id, text, document_id, created_at) values (?, ?, ?, ?);`
+	stmt, err := r.db.Prepare(query)
+	if err != nil {
+		return nil, err
+	}
+	defer stmt.Close()
+
+	disc := &client.Disclaimer{
+		ID:         base.ID(),
+		Text:       text,
+		DocumentID: documentID,
+	}
+	_, err = stmt.Exec(disc.ID, disc.Text, disc.DocumentID, time.Now())
+	return disc, err
+}
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 4a82c51..980e6e7 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -9,8 +9,6 @@ import (
 	"crypto/tls"
 	"flag"
 	"fmt"
-	"github.com/moov-io/customers/internal/database"
-	"github.com/moov-io/customers/internal/version"
 	"net/http"
 	"os"
 	"os/signal"
@@ -21,6 +19,8 @@ import (
 	"github.com/moov-io/base/admin"
 	moovhttp "github.com/moov-io/base/http"
 	"github.com/moov-io/base/http/bind"
+	"github.com/moov-io/customers/internal/database"
+	"github.com/moov-io/customers/internal/version"
 
 	"github.com/go-kit/kit/log"
 	"github.com/gorilla/mux"
@@ -79,6 +79,8 @@ func main() {
 	defer customerRepo.close()
 	customerSSNRepo := &sqlCustomerSSNRepository{db, logger}
 	defer customerSSNRepo.close()
+	disclaimerRepo := &sqlDisclaimerRepository{db, logger}
+	defer disclaimerRepo.close()
 	documentRepo := &sqlDocumentRepository{db, logger}
 	defer documentRepo.close()
 
@@ -124,6 +126,7 @@ func main() {
 	moovhttp.AddCORSHandler(router)
 	addPingRoute(router)
 	addCustomerRoutes(logger, router, customerRepo, customerSSNStorage, ofac)
+	addDisclaimerRoute(logger, router, disclaimerRepo)
 	addDocumentRoutes(logger, router, documentRepo, getBucket(bucketName, cloudProvider, signer))
 
 	// Optionally serve /files/ as our fileblob routes
diff --git a/customers.go b/doc.go
similarity index 100%
rename from customers.go
rename to doc.go
diff --git a/internal/database/mysql.go b/internal/database/mysql.go
index b430fbe..3f9b8f0 100644
--- a/internal/database/mysql.go
+++ b/internal/database/mysql.go
@@ -67,6 +67,14 @@ var (
 			"create_documents",
 			`create table if not exists documents(document_id varchar(40) primary key, customer_id varchar(40), type varchar(120), content_type integer, uploaded_at datetime);`,
 		),
+		execsql(
+			"create_disclaimers",
+			`create table if not exists disclaimers(disclaimer_id varchar(40) primary key, text text, document_id varchar(40), created_at datetime, deleted_at datetime);`,
+		),
+		execsql(
+			"create_disclaimer_acceptances",
+			`create table if not exists disclaimer_acceptances(disclaimer_id varchar(40), customer_id varchar(40), accepted_at datetime, unique(disclaimer_id, customer_id));`,
+		),
 	)
 )
 
diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go
index 0da780c..b4f8117 100644
--- a/internal/database/sqlite.go
+++ b/internal/database/sqlite.go
@@ -63,6 +63,14 @@ var (
 			"create_documents",
 			`create table if not exists documents(document_id primary key, customer_id, type, content_type, uploaded_at datetime);`,
 		),
+		execsql(
+			"create_disclaimers",
+			`create table if not exists disclaimers(disclaimer_id primary key, text, document_id, created_at datetime, deleted_at datetime);`,
+		),
+		execsql(
+			"create_disclaimer_acceptances",
+			`create table if not exists disclaimer_acceptances(disclaimer_id, customer_id, accepted_at datetime, unique(disclaimer_id, customer_id) on conflict ignore);`,
+		),
 	)
 )
 
diff --git a/openapi.yaml b/openapi.yaml
index 5606f63..c480d20 100644
--- a/openapi.yaml
+++ b/openapi.yaml
@@ -643,6 +643,30 @@ components:
         active:
           type: boolean
           description: Address is currently being used for customer
+    Disclaimer:
+      type: object
+      properties:
+        ID:
+          type: string
+          description: The unique identifier for this Disclaimer
+          example: 9342f3a7
+        text:
+          type: string
+          description: Text that user must accept
+          example: Set of conditions
+        documentID:
+          type: string
+          description: Optional ID which references a Document included in the disclaimer
+          example: 08a184e1
+        acceptedAt:
+          type: string
+          description: Timestamp if disclaimer has been accepted, a timestamp before the year 2000 indicates no acceptance.
+          format: date-time
+          example: '2016-08-29T09:12:33.001Z'
+    Disclaimers:
+      type: array
+      items:
+        $ref: '#/components/schemas/Disclaimer'
     Document:
       type: object
       properties:


Test patch:
diff --git a/cmd/server/disclaimers_test.go b/cmd/server/disclaimers_test.go
new file mode 100644
index 0000000..8988dbe
--- /dev/null
+++ b/cmd/server/disclaimers_test.go
@@ -0,0 +1,176 @@
+// Copyright 2019 The Moov Authors
+// Use of this source code is governed by an Apache License
+// license that can be found in the LICENSE file.
+
+package main
+
+import (
+	"encoding/json"
+	"errors"
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/moov-io/base"
+	client "github.com/moov-io/customers/client"
+	"github.com/moov-io/customers/internal/database"
+
+	"github.com/go-kit/kit/log"
+	"github.com/gorilla/mux"
+)
+
+type testDisclaimerRepository struct {
+	disclaimers []*client.Disclaimer
+	err         error
+}
+
+func (r *testDisclaimerRepository) getCustomerDisclaimers(customerID string) ([]*client.Disclaimer, error) {
+	if r.err != nil {
+		return nil, r.err
+	}
+	return r.disclaimers, nil
+}
+
+func (r *testDisclaimerRepository) acceptDisclaimer(customerID, disclaimerID string) error {
+	return r.err
+}
+
+func TestDisclaimers__getDisclaimerID(t *testing.T) {
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest("GET", "/ping", nil)
+	req.Header.Set("x-request-id", "test")
+
+	if id := getDisclaimerID(w, req); id != "" {
+		t.Errorf("unexpected ID: %s", id)
+	}
+}
+
+func TestDisclaimers__getCustomerDisclaimers(t *testing.T) {
+	repo := &testDisclaimerRepository{
+		disclaimers: []*client.Disclaimer{
+			{
+				ID:   base.ID(),
+				Text: "terms and conditions",
+			},
+		},
+	}
+
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest("GET", "/customers/adam/disclaimers", nil)
+	req.Header.Set("x-request-id", "test")
+
+	router := mux.NewRouter()
+	addDisclaimerRoute(log.NewNopLogger(), router, repo)
+	router.ServeHTTP(w, req)
+	w.Flush()
+
+	if w.Code != http.StatusOK {
+		t.Errorf("bogus HTTP status: %d", w.Code)
+	}
+
+	var resp []*client.Disclaimer
+	if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
+		t.Fatal(err)
+	}
+	if len(resp) != 1 {
+		t.Errorf("disclaimers: %#v", resp)
+	}
+
+	// set an error and verify
+	repo.err = errors.New("bad error")
+
+	w = httptest.NewRecorder()
+	router.ServeHTTP(w, req)
+	w.Flush()
+
+	if w.Code != http.StatusBadRequest {
+		t.Errorf("bogus HTTP status: %d", w.Code)
+	}
+}
+
+func TestDisclaimers__acceptDisclaimer(t *testing.T) {
+	disclaimerID := base.ID()
+	repo := &testDisclaimerRepository{
+		disclaimers: []*client.Disclaimer{
+			{
+				ID:   disclaimerID,
+				Text: "terms and conditions",
+			},
+		},
+	}
+
+	w := httptest.NewRecorder()
+	req := httptest.NewRequest("POST", fmt.Sprintf("/customers/adam/disclaimers/%s", disclaimerID), nil)
+	req.Header.Set("x-request-id", "test")
+
+	router := mux.NewRouter()
+	addDisclaimerRoute(log.NewNopLogger(), router, repo)
+	router.ServeHTTP(w, req)
+	w.Flush()
+
+	if w.Code != http.StatusOK {
+		t.Errorf("bogus HTTP status: %d", w.Code)
+	}
+
+	// set an error and verify
+	repo.err = errors.New("bad error")
+
+	w = httptest.NewRecorder()
+	router.ServeHTTP(w, req)
+	w.Flush()
+
+	if w.Code != http.StatusBadRequest {
+		t.Errorf("bogus HTTP status: %d", w.Code)
+	}
+}
+
+func TestDisclaimersRepository(t *testing.T) {
+	customerID := base.ID()
+
+	check := func(t *testing.T, repo *sqlDisclaimerRepository) {
+		defer repo.close()
+
+		disclaimers, err := repo.getCustomerDisclaimers(customerID)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(disclaimers) != 0 {
+			t.Errorf("unexpected disclaimers: %#v", disclaimers)
+		}
+
+		// write a Disclaimer and verify
+		documentID := ""
+		disc, err := repo.insertDisclaimer("terms and conditions", documentID)
+		if err != nil {
+			t.Fatal(err)
+		}
+		disclaimers, err = repo.getCustomerDisclaimers(customerID)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(disclaimers) != 1 {
+			t.Errorf("unexpected disclaimers: %#v", disclaimers)
+		}
+
+		// Accept the disclaimer
+		if err := repo.acceptDisclaimer(customerID, disc.ID); err != nil {
+			t.Fatal(err)
+		}
+
+		// Verify a different disclaimer ID is rejected
+		if err := repo.acceptDisclaimer(customerID, base.ID()); err == nil {
+			t.Error("expected error")
+		}
+	}
+
+	// SQLite tests
+	sqliteDB := database.CreateTestSqliteDB(t)
+	defer sqliteDB.Close()
+	check(t, &sqlDisclaimerRepository{sqliteDB.DB, log.NewNopLogger()})
+
+	// MySQL tests
+	mysqlDB := database.CreateTestMySQLDB(t)
+	defer mysqlDB.Close()
+	check(t, &sqlDisclaimerRepository{mysqlDB.DB, log.NewNopLogger()})
+}
diff --git a/cmd/server/documents_test.go b/cmd/server/documents_test.go
index ca37c21..b7fdb65 100644
--- a/cmd/server/documents_test.go
+++ b/cmd/server/documents_test.go
@@ -9,7 +9,6 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
-	"github.com/moov-io/customers/internal/database"
 	"io"
 	"mime/multipart"
 	"net/http"
@@ -22,6 +21,7 @@ import (
 
 	"github.com/moov-io/base"
 	client "github.com/moov-io/customers/client"
+	"github.com/moov-io/customers/internal/database"
 
 	"github.com/go-kit/kit/log"
 	"github.com/gorilla/mux"
@@ -242,6 +242,7 @@ func TestDocumentRepository(t *testing.T) {
 			t.Errorf("docs[0].ID=%s doc.ID=%s", docs[0].ID, doc.ID)
 		}
 	}
+
 	// SQLite tests
 	sqliteDB := database.CreateTestSqliteDB(t)
 	defer sqliteDB.Close()
@@ -251,5 +252,4 @@ func TestDocumentRepository(t *testing.T) {
 	mysqlDB := database.CreateTestMySQLDB(t)
 	defer mysqlDB.Close()
 	check(t, &sqlDocumentRepository{mysqlDB.DB, log.NewNopLogger()})
-
 }
diff --git a/cmd/server/ofac_test.go b/cmd/server/ofac_test.go
index 98540e7..83743b9 100644
--- a/cmd/server/ofac_test.go
+++ b/cmd/server/ofac_test.go
@@ -65,7 +65,7 @@ func spawnOFAC(t *testing.T) *ofacDeployment {
 	}
 	resource, err := pool.RunWithOptions(&dockertest.RunOptions{
 		Repository: "moov/ofac",
-		Tag:        "v0.11.1",
+		Tag:        "v0.11.0-rc1",
 		Cmd:        []string{"-http.addr=:8080"},
 	})
 	if err != nil {
@@ -111,7 +111,7 @@ func TestOFAC__search(t *testing.T) {
 	}
 
 	// Search query that matches an AltName higher than SDN
-	sdn, err = deployment.client.Search(ctx, "Osama BIN LADIN", base.ID())
+	sdn, err = deployment.client.Search(ctx, "Osama bin Muhammad bin Awad BIN LADIN", base.ID())
 	if err != nil || sdn == nil {
 		t.Fatalf("sdn=%v err=%v", sdn, err)
 	}