diff --git a/pkg/gen/ghcapi/configure_mymove.go b/pkg/gen/ghcapi/configure_mymove.go index 72004f41350..86b0fd7c50e 100644 --- a/pkg/gen/ghcapi/configure_mymove.go +++ b/pkg/gen/ghcapi/configure_mymove.go @@ -348,6 +348,11 @@ func configureAPI(api *ghcoperations.MymoveAPI) http.Handler { return middleware.NotImplemented("operation evaluation_reports.SaveEvaluationReport has not yet been implemented") }) } + if api.CustomerSearchCustomersHandler == nil { + api.CustomerSearchCustomersHandler = customer.SearchCustomersHandlerFunc(func(params customer.SearchCustomersParams) middleware.Responder { + return middleware.NotImplemented("operation customer.SearchCustomers has not yet been implemented") + }) + } if api.MoveSearchMovesHandler == nil { api.MoveSearchMovesHandler = move.SearchMovesHandlerFunc(func(params move.SearchMovesParams) middleware.Responder { return middleware.NotImplemented("operation move.SearchMoves has not yet been implemented") diff --git a/pkg/gen/ghcapi/embedded_spec.go b/pkg/gen/ghcapi/embedded_spec.go index 97f6cba63b6..70b885daac7 100644 --- a/pkg/gen/ghcapi/embedded_spec.go +++ b/pkg/gen/ghcapi/embedded_spec.go @@ -314,6 +314,91 @@ func init() { } ] }, + "/customer/search": { + "post": { + "description": "Search customers by DOD ID or customer name. Used by services counselors to locate profiles to update, find attached moves, and to create new moves.\n", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "customer" + ], + "summary": "Search customers by DOD ID or customer name", + "operationId": "searchCustomers", + "parameters": [ + { + "description": "field that results should be sorted by", + "name": "body", + "in": "body", + "schema": { + "properties": { + "branch": { + "description": "Branch", + "type": "string", + "minLength": 1 + }, + "customerName": { + "description": "Customer Name", + "type": "string", + "minLength": 1, + "x-nullable": true + }, + "dodID": { + "description": "DOD ID", + "type": "string", + "maxLength": 10, + "minLength": 10, + "x-nullable": true + }, + "order": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "x-nullable": true + }, + "page": { + "description": "requested page of results", + "type": "integer" + }, + "perPage": { + "type": "integer" + }, + "sort": { + "type": "string", + "enum": [ + "customerName", + "dodID", + "branch", + "personalEmail", + "telephone" + ], + "x-nullable": true + } + } + } + } + ], + "responses": { + "200": { + "description": "Successfully returned all customers matching the criteria", + "schema": { + "$ref": "#/definitions/SearchCustomersResult" + } + }, + "403": { + "$ref": "#/responses/PermissionDenied" + }, + "500": { + "$ref": "#/responses/ServerError" + } + } + } + }, "/customer/{customerID}": { "get": { "description": "Returns a given customer", @@ -9881,6 +9966,67 @@ func init() { } } }, + "SearchCustomer": { + "type": "object", + "properties": { + "branch": { + "type": "string" + }, + "dodID": { + "type": "string", + "x-nullable": true + }, + "firstName": { + "type": "string", + "x-nullable": true, + "example": "John" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastName": { + "type": "string", + "x-nullable": true, + "example": "Doe" + }, + "personalEmail": { + "type": "string", + "format": "x-email", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "example": "personalEmail@email.com" + }, + "telephone": { + "type": "string", + "format": "telephone", + "pattern": "^[2-9]\\d{2}-\\d{3}-\\d{4}$", + "x-nullable": true + } + } + }, + "SearchCustomers": { + "type": "array", + "items": { + "$ref": "#/definitions/SearchCustomer" + } + }, + "SearchCustomersResult": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "perPage": { + "type": "integer" + }, + "searchCustomers": { + "$ref": "#/definitions/SearchCustomers" + }, + "totalCount": { + "type": "integer" + } + } + }, "SearchMove": { "type": "object", "properties": { @@ -11821,6 +11967,97 @@ func init() { } ] }, + "/customer/search": { + "post": { + "description": "Search customers by DOD ID or customer name. Used by services counselors to locate profiles to update, find attached moves, and to create new moves.\n", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "customer" + ], + "summary": "Search customers by DOD ID or customer name", + "operationId": "searchCustomers", + "parameters": [ + { + "description": "field that results should be sorted by", + "name": "body", + "in": "body", + "schema": { + "properties": { + "branch": { + "description": "Branch", + "type": "string", + "minLength": 1 + }, + "customerName": { + "description": "Customer Name", + "type": "string", + "minLength": 1, + "x-nullable": true + }, + "dodID": { + "description": "DOD ID", + "type": "string", + "maxLength": 10, + "minLength": 10, + "x-nullable": true + }, + "order": { + "type": "string", + "enum": [ + "asc", + "desc" + ], + "x-nullable": true + }, + "page": { + "description": "requested page of results", + "type": "integer" + }, + "perPage": { + "type": "integer" + }, + "sort": { + "type": "string", + "enum": [ + "customerName", + "dodID", + "branch", + "personalEmail", + "telephone" + ], + "x-nullable": true + } + } + } + } + ], + "responses": { + "200": { + "description": "Successfully returned all customers matching the criteria", + "schema": { + "$ref": "#/definitions/SearchCustomersResult" + } + }, + "403": { + "description": "The request was denied", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "500": { + "description": "A server error occurred", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + } + }, "/customer/{customerID}": { "get": { "description": "Returns a given customer", @@ -22684,6 +22921,67 @@ func init() { } } }, + "SearchCustomer": { + "type": "object", + "properties": { + "branch": { + "type": "string" + }, + "dodID": { + "type": "string", + "x-nullable": true + }, + "firstName": { + "type": "string", + "x-nullable": true, + "example": "John" + }, + "id": { + "type": "string", + "format": "uuid" + }, + "lastName": { + "type": "string", + "x-nullable": true, + "example": "Doe" + }, + "personalEmail": { + "type": "string", + "format": "x-email", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$", + "example": "personalEmail@email.com" + }, + "telephone": { + "type": "string", + "format": "telephone", + "pattern": "^[2-9]\\d{2}-\\d{3}-\\d{4}$", + "x-nullable": true + } + } + }, + "SearchCustomers": { + "type": "array", + "items": { + "$ref": "#/definitions/SearchCustomer" + } + }, + "SearchCustomersResult": { + "type": "object", + "properties": { + "page": { + "type": "integer" + }, + "perPage": { + "type": "integer" + }, + "searchCustomers": { + "$ref": "#/definitions/SearchCustomers" + }, + "totalCount": { + "type": "integer" + } + } + }, "SearchMove": { "type": "object", "properties": { diff --git a/pkg/gen/ghcapi/ghcoperations/customer/search_customers.go b/pkg/gen/ghcapi/ghcoperations/customer/search_customers.go new file mode 100644 index 00000000000..17a6758e465 --- /dev/null +++ b/pkg/gen/ghcapi/ghcoperations/customer/search_customers.go @@ -0,0 +1,283 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package customer + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the generate command + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// SearchCustomersHandlerFunc turns a function with the right signature into a search customers handler +type SearchCustomersHandlerFunc func(SearchCustomersParams) middleware.Responder + +// Handle executing the request and returning a response +func (fn SearchCustomersHandlerFunc) Handle(params SearchCustomersParams) middleware.Responder { + return fn(params) +} + +// SearchCustomersHandler interface for that can handle valid search customers params +type SearchCustomersHandler interface { + Handle(SearchCustomersParams) middleware.Responder +} + +// NewSearchCustomers creates a new http.Handler for the search customers operation +func NewSearchCustomers(ctx *middleware.Context, handler SearchCustomersHandler) *SearchCustomers { + return &SearchCustomers{Context: ctx, Handler: handler} +} + +/* + SearchCustomers swagger:route POST /customer/search customer searchCustomers + +# Search customers by DOD ID or customer name + +Search customers by DOD ID or customer name. Used by services counselors to locate profiles to update, find attached moves, and to create new moves. +*/ +type SearchCustomers struct { + Context *middleware.Context + Handler SearchCustomersHandler +} + +func (o *SearchCustomers) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + route, rCtx, _ := o.Context.RouteInfo(r) + if rCtx != nil { + *r = *rCtx + } + var Params = NewSearchCustomersParams() + if err := o.Context.BindValidRequest(r, route, &Params); err != nil { // bind params + o.Context.Respond(rw, r, route.Produces, route, err) + return + } + + res := o.Handler.Handle(Params) // actually handle the request + o.Context.Respond(rw, r, route.Produces, route, res) + +} + +// SearchCustomersBody search customers body +// +// swagger:model SearchCustomersBody +type SearchCustomersBody struct { + + // Branch + // Min Length: 1 + Branch string `json:"branch,omitempty"` + + // Customer Name + // Min Length: 1 + CustomerName *string `json:"customerName,omitempty"` + + // DOD ID + // Max Length: 10 + // Min Length: 10 + DodID *string `json:"dodID,omitempty"` + + // order + // Enum: [asc desc] + Order *string `json:"order,omitempty"` + + // requested page of results + Page int64 `json:"page,omitempty"` + + // per page + PerPage int64 `json:"perPage,omitempty"` + + // sort + // Enum: [customerName dodID branch personalEmail telephone] + Sort *string `json:"sort,omitempty"` +} + +// Validate validates this search customers body +func (o *SearchCustomersBody) Validate(formats strfmt.Registry) error { + var res []error + + if err := o.validateBranch(formats); err != nil { + res = append(res, err) + } + + if err := o.validateCustomerName(formats); err != nil { + res = append(res, err) + } + + if err := o.validateDodID(formats); err != nil { + res = append(res, err) + } + + if err := o.validateOrder(formats); err != nil { + res = append(res, err) + } + + if err := o.validateSort(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (o *SearchCustomersBody) validateBranch(formats strfmt.Registry) error { + if swag.IsZero(o.Branch) { // not required + return nil + } + + if err := validate.MinLength("body"+"."+"branch", "body", o.Branch, 1); err != nil { + return err + } + + return nil +} + +func (o *SearchCustomersBody) validateCustomerName(formats strfmt.Registry) error { + if swag.IsZero(o.CustomerName) { // not required + return nil + } + + if err := validate.MinLength("body"+"."+"customerName", "body", *o.CustomerName, 1); err != nil { + return err + } + + return nil +} + +func (o *SearchCustomersBody) validateDodID(formats strfmt.Registry) error { + if swag.IsZero(o.DodID) { // not required + return nil + } + + if err := validate.MinLength("body"+"."+"dodID", "body", *o.DodID, 10); err != nil { + return err + } + + if err := validate.MaxLength("body"+"."+"dodID", "body", *o.DodID, 10); err != nil { + return err + } + + return nil +} + +var searchCustomersBodyTypeOrderPropEnum []interface{} + +func init() { + var res []string + if err := json.Unmarshal([]byte(`["asc","desc"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + searchCustomersBodyTypeOrderPropEnum = append(searchCustomersBodyTypeOrderPropEnum, v) + } +} + +const ( + + // SearchCustomersBodyOrderAsc captures enum value "asc" + SearchCustomersBodyOrderAsc string = "asc" + + // SearchCustomersBodyOrderDesc captures enum value "desc" + SearchCustomersBodyOrderDesc string = "desc" +) + +// prop value enum +func (o *SearchCustomersBody) validateOrderEnum(path, location string, value string) error { + if err := validate.EnumCase(path, location, value, searchCustomersBodyTypeOrderPropEnum, true); err != nil { + return err + } + return nil +} + +func (o *SearchCustomersBody) validateOrder(formats strfmt.Registry) error { + if swag.IsZero(o.Order) { // not required + return nil + } + + // value enum + if err := o.validateOrderEnum("body"+"."+"order", "body", *o.Order); err != nil { + return err + } + + return nil +} + +var searchCustomersBodyTypeSortPropEnum []interface{} + +func init() { + var res []string + if err := json.Unmarshal([]byte(`["customerName","dodID","branch","personalEmail","telephone"]`), &res); err != nil { + panic(err) + } + for _, v := range res { + searchCustomersBodyTypeSortPropEnum = append(searchCustomersBodyTypeSortPropEnum, v) + } +} + +const ( + + // SearchCustomersBodySortCustomerName captures enum value "customerName" + SearchCustomersBodySortCustomerName string = "customerName" + + // SearchCustomersBodySortDodID captures enum value "dodID" + SearchCustomersBodySortDodID string = "dodID" + + // SearchCustomersBodySortBranch captures enum value "branch" + SearchCustomersBodySortBranch string = "branch" + + // SearchCustomersBodySortPersonalEmail captures enum value "personalEmail" + SearchCustomersBodySortPersonalEmail string = "personalEmail" + + // SearchCustomersBodySortTelephone captures enum value "telephone" + SearchCustomersBodySortTelephone string = "telephone" +) + +// prop value enum +func (o *SearchCustomersBody) validateSortEnum(path, location string, value string) error { + if err := validate.EnumCase(path, location, value, searchCustomersBodyTypeSortPropEnum, true); err != nil { + return err + } + return nil +} + +func (o *SearchCustomersBody) validateSort(formats strfmt.Registry) error { + if swag.IsZero(o.Sort) { // not required + return nil + } + + // value enum + if err := o.validateSortEnum("body"+"."+"sort", "body", *o.Sort); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this search customers body based on context it is used +func (o *SearchCustomersBody) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (o *SearchCustomersBody) MarshalBinary() ([]byte, error) { + if o == nil { + return nil, nil + } + return swag.WriteJSON(o) +} + +// UnmarshalBinary interface implementation +func (o *SearchCustomersBody) UnmarshalBinary(b []byte) error { + var res SearchCustomersBody + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *o = res + return nil +} diff --git a/pkg/gen/ghcapi/ghcoperations/customer/search_customers_parameters.go b/pkg/gen/ghcapi/ghcoperations/customer/search_customers_parameters.go new file mode 100644 index 00000000000..ca6d419ced9 --- /dev/null +++ b/pkg/gen/ghcapi/ghcoperations/customer/search_customers_parameters.go @@ -0,0 +1,74 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package customer + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/errors" + "github.com/go-openapi/runtime" + "github.com/go-openapi/runtime/middleware" + "github.com/go-openapi/validate" +) + +// NewSearchCustomersParams creates a new SearchCustomersParams object +// +// There are no default values defined in the spec. +func NewSearchCustomersParams() SearchCustomersParams { + + return SearchCustomersParams{} +} + +// SearchCustomersParams contains all the bound params for the search customers operation +// typically these are obtained from a http.Request +// +// swagger:parameters searchCustomers +type SearchCustomersParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /*field that results should be sorted by + In: body + */ + Body SearchCustomersBody +} + +// BindRequest both binds and validates a request, it assumes that complex things implement a Validatable(strfmt.Registry) error interface +// for simple values it will use straight method calls. +// +// To ensure default values, the struct must have been initialized with NewSearchCustomersParams() beforehand. +func (o *SearchCustomersParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + if runtime.HasBody(r) { + defer r.Body.Close() + var body SearchCustomersBody + if err := route.Consumer.Consume(r.Body, &body); err != nil { + res = append(res, errors.NewParseError("body", "body", "", err)) + } else { + // validate body object + if err := body.Validate(route.Formats); err != nil { + res = append(res, err) + } + + ctx := validate.WithOperationRequest(r.Context()) + if err := body.ContextValidate(ctx, route.Formats); err != nil { + res = append(res, err) + } + + if len(res) == 0 { + o.Body = body + } + } + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/pkg/gen/ghcapi/ghcoperations/customer/search_customers_responses.go b/pkg/gen/ghcapi/ghcoperations/customer/search_customers_responses.go new file mode 100644 index 00000000000..952e4e60a89 --- /dev/null +++ b/pkg/gen/ghcapi/ghcoperations/customer/search_customers_responses.go @@ -0,0 +1,149 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package customer + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "net/http" + + "github.com/go-openapi/runtime" + + "github.com/transcom/mymove/pkg/gen/ghcmessages" +) + +// SearchCustomersOKCode is the HTTP code returned for type SearchCustomersOK +const SearchCustomersOKCode int = 200 + +/* +SearchCustomersOK Successfully returned all customers matching the criteria + +swagger:response searchCustomersOK +*/ +type SearchCustomersOK struct { + + /* + In: Body + */ + Payload *ghcmessages.SearchCustomersResult `json:"body,omitempty"` +} + +// NewSearchCustomersOK creates SearchCustomersOK with default headers values +func NewSearchCustomersOK() *SearchCustomersOK { + + return &SearchCustomersOK{} +} + +// WithPayload adds the payload to the search customers o k response +func (o *SearchCustomersOK) WithPayload(payload *ghcmessages.SearchCustomersResult) *SearchCustomersOK { + o.Payload = payload + return o +} + +// SetPayload sets the payload to the search customers o k response +func (o *SearchCustomersOK) SetPayload(payload *ghcmessages.SearchCustomersResult) { + o.Payload = payload +} + +// WriteResponse to the client +func (o *SearchCustomersOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.WriteHeader(200) + if o.Payload != nil { + payload := o.Payload + if err := producer.Produce(rw, payload); err != nil { + panic(err) // let the recovery middleware deal with this + } + } +} + +// SearchCustomersForbiddenCode is the HTTP code returned for type SearchCustomersForbidden +const SearchCustomersForbiddenCode int = 403 + +/* +SearchCustomersForbidden The request was denied + +swagger:response searchCustomersForbidden +*/ +type SearchCustomersForbidden struct { + + /* + In: Body + */ + Payload *ghcmessages.Error `json:"body,omitempty"` +} + +// NewSearchCustomersForbidden creates SearchCustomersForbidden with default headers values +func NewSearchCustomersForbidden() *SearchCustomersForbidden { + + return &SearchCustomersForbidden{} +} + +// WithPayload adds the payload to the search customers forbidden response +func (o *SearchCustomersForbidden) WithPayload(payload *ghcmessages.Error) *SearchCustomersForbidden { + o.Payload = payload + return o +} + +// SetPayload sets the payload to the search customers forbidden response +func (o *SearchCustomersForbidden) SetPayload(payload *ghcmessages.Error) { + o.Payload = payload +} + +// WriteResponse to the client +func (o *SearchCustomersForbidden) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.WriteHeader(403) + if o.Payload != nil { + payload := o.Payload + if err := producer.Produce(rw, payload); err != nil { + panic(err) // let the recovery middleware deal with this + } + } +} + +// SearchCustomersInternalServerErrorCode is the HTTP code returned for type SearchCustomersInternalServerError +const SearchCustomersInternalServerErrorCode int = 500 + +/* +SearchCustomersInternalServerError A server error occurred + +swagger:response searchCustomersInternalServerError +*/ +type SearchCustomersInternalServerError struct { + + /* + In: Body + */ + Payload *ghcmessages.Error `json:"body,omitempty"` +} + +// NewSearchCustomersInternalServerError creates SearchCustomersInternalServerError with default headers values +func NewSearchCustomersInternalServerError() *SearchCustomersInternalServerError { + + return &SearchCustomersInternalServerError{} +} + +// WithPayload adds the payload to the search customers internal server error response +func (o *SearchCustomersInternalServerError) WithPayload(payload *ghcmessages.Error) *SearchCustomersInternalServerError { + o.Payload = payload + return o +} + +// SetPayload sets the payload to the search customers internal server error response +func (o *SearchCustomersInternalServerError) SetPayload(payload *ghcmessages.Error) { + o.Payload = payload +} + +// WriteResponse to the client +func (o *SearchCustomersInternalServerError) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.WriteHeader(500) + if o.Payload != nil { + payload := o.Payload + if err := producer.Produce(rw, payload); err != nil { + panic(err) // let the recovery middleware deal with this + } + } +} diff --git a/pkg/gen/ghcapi/ghcoperations/customer/search_customers_urlbuilder.go b/pkg/gen/ghcapi/ghcoperations/customer/search_customers_urlbuilder.go new file mode 100644 index 00000000000..06d228d61fd --- /dev/null +++ b/pkg/gen/ghcapi/ghcoperations/customer/search_customers_urlbuilder.go @@ -0,0 +1,87 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package customer + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the generate command + +import ( + "errors" + "net/url" + golangswaggerpaths "path" +) + +// SearchCustomersURL generates an URL for the search customers operation +type SearchCustomersURL struct { + _basePath string +} + +// WithBasePath sets the base path for this url builder, only required when it's different from the +// base path specified in the swagger spec. +// When the value of the base path is an empty string +func (o *SearchCustomersURL) WithBasePath(bp string) *SearchCustomersURL { + o.SetBasePath(bp) + return o +} + +// SetBasePath sets the base path for this url builder, only required when it's different from the +// base path specified in the swagger spec. +// When the value of the base path is an empty string +func (o *SearchCustomersURL) SetBasePath(bp string) { + o._basePath = bp +} + +// Build a url path and query string +func (o *SearchCustomersURL) Build() (*url.URL, error) { + var _result url.URL + + var _path = "/customer/search" + + _basePath := o._basePath + if _basePath == "" { + _basePath = "/ghc/v1" + } + _result.Path = golangswaggerpaths.Join(_basePath, _path) + + return &_result, nil +} + +// Must is a helper function to panic when the url builder returns an error +func (o *SearchCustomersURL) Must(u *url.URL, err error) *url.URL { + if err != nil { + panic(err) + } + if u == nil { + panic("url can't be nil") + } + return u +} + +// String returns the string representation of the path with query string +func (o *SearchCustomersURL) String() string { + return o.Must(o.Build()).String() +} + +// BuildFull builds a full url with scheme, host, path and query string +func (o *SearchCustomersURL) BuildFull(scheme, host string) (*url.URL, error) { + if scheme == "" { + return nil, errors.New("scheme is required for a full url on SearchCustomersURL") + } + if host == "" { + return nil, errors.New("host is required for a full url on SearchCustomersURL") + } + + base, err := o.Build() + if err != nil { + return nil, err + } + + base.Scheme = scheme + base.Host = host + return base, nil +} + +// StringFull returns the string representation of a complete url +func (o *SearchCustomersURL) StringFull(scheme, host string) string { + return o.Must(o.BuildFull(scheme, host)).String() +} diff --git a/pkg/gen/ghcapi/ghcoperations/mymove_api.go b/pkg/gen/ghcapi/ghcoperations/mymove_api.go index 2642916b3d7..ecaced2a301 100644 --- a/pkg/gen/ghcapi/ghcoperations/mymove_api.go +++ b/pkg/gen/ghcapi/ghcoperations/mymove_api.go @@ -237,6 +237,9 @@ func NewMymoveAPI(spec *loads.Document) *MymoveAPI { EvaluationReportsSaveEvaluationReportHandler: evaluation_reports.SaveEvaluationReportHandlerFunc(func(params evaluation_reports.SaveEvaluationReportParams) middleware.Responder { return middleware.NotImplemented("operation evaluation_reports.SaveEvaluationReport has not yet been implemented") }), + CustomerSearchCustomersHandler: customer.SearchCustomersHandlerFunc(func(params customer.SearchCustomersParams) middleware.Responder { + return middleware.NotImplemented("operation customer.SearchCustomers has not yet been implemented") + }), MoveSearchMovesHandler: move.SearchMovesHandlerFunc(func(params move.SearchMovesParams) middleware.Responder { return middleware.NotImplemented("operation move.SearchMoves has not yet been implemented") }), @@ -475,6 +478,8 @@ type MymoveAPI struct { ShipmentReviewShipmentAddressUpdateHandler shipment.ReviewShipmentAddressUpdateHandler // EvaluationReportsSaveEvaluationReportHandler sets the operation handler for the save evaluation report operation EvaluationReportsSaveEvaluationReportHandler evaluation_reports.SaveEvaluationReportHandler + // CustomerSearchCustomersHandler sets the operation handler for the search customers operation + CustomerSearchCustomersHandler customer.SearchCustomersHandler // MoveSearchMovesHandler sets the operation handler for the search moves operation MoveSearchMovesHandler move.SearchMovesHandler // MoveSetFinancialReviewFlagHandler sets the operation handler for the set financial review flag operation @@ -781,6 +786,9 @@ func (o *MymoveAPI) Validate() error { if o.EvaluationReportsSaveEvaluationReportHandler == nil { unregistered = append(unregistered, "evaluation_reports.SaveEvaluationReportHandler") } + if o.CustomerSearchCustomersHandler == nil { + unregistered = append(unregistered, "customer.SearchCustomersHandler") + } if o.MoveSearchMovesHandler == nil { unregistered = append(unregistered, "move.SearchMovesHandler") } @@ -1182,6 +1190,10 @@ func (o *MymoveAPI) initHandlerCache() { if o.handlers["POST"] == nil { o.handlers["POST"] = make(map[string]http.Handler) } + o.handlers["POST"]["/customer/search"] = customer.NewSearchCustomers(o.context, o.CustomerSearchCustomersHandler) + if o.handlers["POST"] == nil { + o.handlers["POST"] = make(map[string]http.Handler) + } o.handlers["POST"]["/moves/search"] = move.NewSearchMoves(o.context, o.MoveSearchMovesHandler) if o.handlers["POST"] == nil { o.handlers["POST"] = make(map[string]http.Handler) diff --git a/pkg/gen/ghcmessages/search_customer.go b/pkg/gen/ghcmessages/search_customer.go new file mode 100644 index 00000000000..b09384dbf43 --- /dev/null +++ b/pkg/gen/ghcmessages/search_customer.go @@ -0,0 +1,129 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package ghcmessages + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" + "github.com/go-openapi/validate" +) + +// SearchCustomer search customer +// +// swagger:model SearchCustomer +type SearchCustomer struct { + + // branch + Branch string `json:"branch,omitempty"` + + // dod ID + DodID *string `json:"dodID,omitempty"` + + // first name + // Example: John + FirstName *string `json:"firstName,omitempty"` + + // id + // Format: uuid + ID strfmt.UUID `json:"id,omitempty"` + + // last name + // Example: Doe + LastName *string `json:"lastName,omitempty"` + + // personal email + // Example: personalEmail@email.com + // Pattern: ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ + PersonalEmail string `json:"personalEmail,omitempty"` + + // telephone + // Pattern: ^[2-9]\d{2}-\d{3}-\d{4}$ + Telephone *string `json:"telephone,omitempty"` +} + +// Validate validates this search customer +func (m *SearchCustomer) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateID(formats); err != nil { + res = append(res, err) + } + + if err := m.validatePersonalEmail(formats); err != nil { + res = append(res, err) + } + + if err := m.validateTelephone(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *SearchCustomer) validateID(formats strfmt.Registry) error { + if swag.IsZero(m.ID) { // not required + return nil + } + + if err := validate.FormatOf("id", "body", "uuid", m.ID.String(), formats); err != nil { + return err + } + + return nil +} + +func (m *SearchCustomer) validatePersonalEmail(formats strfmt.Registry) error { + if swag.IsZero(m.PersonalEmail) { // not required + return nil + } + + if err := validate.Pattern("personalEmail", "body", m.PersonalEmail, `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`); err != nil { + return err + } + + return nil +} + +func (m *SearchCustomer) validateTelephone(formats strfmt.Registry) error { + if swag.IsZero(m.Telephone) { // not required + return nil + } + + if err := validate.Pattern("telephone", "body", *m.Telephone, `^[2-9]\d{2}-\d{3}-\d{4}$`); err != nil { + return err + } + + return nil +} + +// ContextValidate validates this search customer based on context it is used +func (m *SearchCustomer) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + return nil +} + +// MarshalBinary interface implementation +func (m *SearchCustomer) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *SearchCustomer) UnmarshalBinary(b []byte) error { + var res SearchCustomer + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/pkg/gen/ghcmessages/search_customers.go b/pkg/gen/ghcmessages/search_customers.go new file mode 100644 index 00000000000..5b090aea851 --- /dev/null +++ b/pkg/gen/ghcmessages/search_customers.go @@ -0,0 +1,78 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package ghcmessages + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + "strconv" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// SearchCustomers search customers +// +// swagger:model SearchCustomers +type SearchCustomers []*SearchCustomer + +// Validate validates this search customers +func (m SearchCustomers) Validate(formats strfmt.Registry) error { + var res []error + + for i := 0; i < len(m); i++ { + if swag.IsZero(m[i]) { // not required + continue + } + + if m[i] != nil { + if err := m[i].Validate(formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName(strconv.Itoa(i)) + } else if ce, ok := err.(*errors.CompositeError); ok { + return ce.ValidateName(strconv.Itoa(i)) + } + return err + } + } + + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +// ContextValidate validate this search customers based on the context it is used +func (m SearchCustomers) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + for i := 0; i < len(m); i++ { + + if m[i] != nil { + + if swag.IsZero(m[i]) { // not required + return nil + } + + if err := m[i].ContextValidate(ctx, formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName(strconv.Itoa(i)) + } else if ce, ok := err.(*errors.CompositeError); ok { + return ce.ValidateName(strconv.Itoa(i)) + } + return err + } + } + + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} diff --git a/pkg/gen/ghcmessages/search_customers_result.go b/pkg/gen/ghcmessages/search_customers_result.go new file mode 100644 index 00000000000..5abcaf215ec --- /dev/null +++ b/pkg/gen/ghcmessages/search_customers_result.go @@ -0,0 +1,109 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package ghcmessages + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "context" + + "github.com/go-openapi/errors" + "github.com/go-openapi/strfmt" + "github.com/go-openapi/swag" +) + +// SearchCustomersResult search customers result +// +// swagger:model SearchCustomersResult +type SearchCustomersResult struct { + + // page + Page int64 `json:"page,omitempty"` + + // per page + PerPage int64 `json:"perPage,omitempty"` + + // search customers + SearchCustomers SearchCustomers `json:"searchCustomers,omitempty"` + + // total count + TotalCount int64 `json:"totalCount,omitempty"` +} + +// Validate validates this search customers result +func (m *SearchCustomersResult) Validate(formats strfmt.Registry) error { + var res []error + + if err := m.validateSearchCustomers(formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *SearchCustomersResult) validateSearchCustomers(formats strfmt.Registry) error { + if swag.IsZero(m.SearchCustomers) { // not required + return nil + } + + if err := m.SearchCustomers.Validate(formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("searchCustomers") + } else if ce, ok := err.(*errors.CompositeError); ok { + return ce.ValidateName("searchCustomers") + } + return err + } + + return nil +} + +// ContextValidate validate this search customers result based on the context it is used +func (m *SearchCustomersResult) ContextValidate(ctx context.Context, formats strfmt.Registry) error { + var res []error + + if err := m.contextValidateSearchCustomers(ctx, formats); err != nil { + res = append(res, err) + } + + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +func (m *SearchCustomersResult) contextValidateSearchCustomers(ctx context.Context, formats strfmt.Registry) error { + + if err := m.SearchCustomers.ContextValidate(ctx, formats); err != nil { + if ve, ok := err.(*errors.Validation); ok { + return ve.ValidateName("searchCustomers") + } else if ce, ok := err.(*errors.CompositeError); ok { + return ce.ValidateName("searchCustomers") + } + return err + } + + return nil +} + +// MarshalBinary interface implementation +func (m *SearchCustomersResult) MarshalBinary() ([]byte, error) { + if m == nil { + return nil, nil + } + return swag.WriteJSON(m) +} + +// UnmarshalBinary interface implementation +func (m *SearchCustomersResult) UnmarshalBinary(b []byte) error { + var res SearchCustomersResult + if err := swag.ReadJSON(b, &res); err != nil { + return err + } + *m = res + return nil +} diff --git a/pkg/handlers/ghcapi/api.go b/pkg/handlers/ghcapi/api.go index 4427571df77..7f66f43fcb0 100644 --- a/pkg/handlers/ghcapi/api.go +++ b/pkg/handlers/ghcapi/api.go @@ -581,5 +581,10 @@ func NewGhcAPIHandler(handlerConfig handlers.HandlerConfig) *ghcops.MymoveAPI { ghcAPI.UploadsCreateUploadHandler = CreateUploadHandler{handlerConfig} + ghcAPI.CustomerSearchCustomersHandler = SearchCustomersHandler{ + HandlerConfig: handlerConfig, + CustomerSearcher: customer.NewCustomerSearcher(), + } + return ghcAPI } diff --git a/pkg/handlers/ghcapi/customer.go b/pkg/handlers/ghcapi/customer.go index 7b7093c9f08..0c57a646cd9 100644 --- a/pkg/handlers/ghcapi/customer.go +++ b/pkg/handlers/ghcapi/customer.go @@ -68,6 +68,41 @@ func (h GetCustomerHandler) Handle(params customercodeop.GetCustomerParams) midd }) } +type SearchCustomersHandler struct { + handlers.HandlerConfig + services.CustomerSearcher +} + +func (h SearchCustomersHandler) Handle(params customercodeop.SearchCustomersParams) middleware.Responder { + return h.AuditableAppContextFromRequestWithErrors(params.HTTPRequest, + func(appCtx appcontext.AppContext) (middleware.Responder, error) { + searchCustomersParams := services.SearchCustomersParams{ + DodID: params.Body.DodID, + CustomerName: params.Body.CustomerName, + Page: params.Body.Page, + PerPage: params.Body.PerPage, + Sort: params.Body.Sort, + Order: params.Body.Order, + } + + customers, totalCount, err := h.CustomerSearcher.SearchCustomers(appCtx, &searchCustomersParams) + + if err != nil { + appCtx.Logger().Error("Error searching for customer", zap.Error(err)) + return customercodeop.NewSearchCustomersInternalServerError(), err + } + + searchCustomers := payloads.SearchCustomers(customers) + payload := &ghcmessages.SearchCustomersResult{ + Page: 1, + PerPage: 20, + TotalCount: int64(totalCount), + SearchCustomers: *searchCustomers, + } + return customercodeop.NewSearchCustomersOK().WithPayload(payload), nil + }) +} + // UpdateCustomerHandler updates a customer via PATCH /customer/{customerId} type UpdateCustomerHandler struct { handlers.HandlerConfig diff --git a/pkg/handlers/ghcapi/customer_test.go b/pkg/handlers/ghcapi/customer_test.go index be3ac6b270b..3f17a96cfc9 100644 --- a/pkg/handlers/ghcapi/customer_test.go +++ b/pkg/handlers/ghcapi/customer_test.go @@ -2,11 +2,13 @@ package ghcapi import ( "fmt" + "net/http" "net/http/httptest" "github.com/go-openapi/strfmt" "github.com/jarcoal/httpmock" "github.com/markbates/goth" + "github.com/stretchr/testify/mock" "github.com/transcom/mymove/pkg/etag" "github.com/transcom/mymove/pkg/factory" @@ -14,7 +16,10 @@ import ( "github.com/transcom/mymove/pkg/gen/ghcmessages" "github.com/transcom/mymove/pkg/handlers" "github.com/transcom/mymove/pkg/handlers/authentication/okta" + "github.com/transcom/mymove/pkg/models" "github.com/transcom/mymove/pkg/models/roles" + "github.com/transcom/mymove/pkg/services" + "github.com/transcom/mymove/pkg/services/mocks" customerservice "github.com/transcom/mymove/pkg/services/office_user/customer" ) @@ -75,7 +80,7 @@ func (suite *HandlerSuite) TestUpdateCustomerHandler() { StreetAddress1: handlers.FmtString("123 New Street"), City: handlers.FmtString("Newcity"), State: handlers.FmtString("MA"), - PostalCode: handlers.FmtString("02110"), + PostalCode: handlers.FmtString("12345"), } body.CurrentAddress.Address = currentAddress @@ -212,6 +217,86 @@ func (suite *HandlerSuite) TestCreateCustomerWithOktaOptionHandler() { suite.Equal(true, createdCustomerPayload.CacValidated) } +func (suite *HandlerSuite) TestSearchCustomersHandler() { + var requestUser models.User + setupTestData := func() *http.Request { + requestUser = factory.BuildUser(nil, nil, nil) + req := httptest.NewRequest("GET", "/customer/#{customer.id}", nil) + req = suite.AuthenticateUserRequest(req, requestUser) + return req + } + + suite.Run("Successful customer search by DOD ID", func() { + req := setupTestData() + customer := factory.BuildServiceMember(suite.DB(), nil, nil) + customers := models.ServiceMembers{customer} + + mockSearcher := mocks.CustomerSearcher{} + + handler := SearchCustomersHandler{ + HandlerConfig: suite.HandlerConfig(), + CustomerSearcher: &mockSearcher, + } + mockSearcher.On("SearchCustomers", + mock.AnythingOfType("*appcontext.appContext"), + mock.MatchedBy(func(params *services.SearchCustomersParams) bool { + return *params.DodID == *customer.Edipi && + params.CustomerName == nil + }), + ).Return(customers, 1, nil) + + params := customerops.SearchCustomersParams{ + HTTPRequest: req, + Body: customerops.SearchCustomersBody{ + DodID: customer.Edipi, + }, + } + + suite.NoError(params.Body.Validate(strfmt.Default)) + response := handler.Handle(params) + suite.IsType(&customerops.SearchCustomersOK{}, response) + payload := response.(*customerops.SearchCustomersOK).Payload + suite.NoError(payload.Validate(strfmt.Default)) + + suite.Equal(customer.ID.String(), (*payload).SearchCustomers[0].ID.String()) + }) + + suite.Run("Successful customer search by name", func() { + req := setupTestData() + customer := factory.BuildServiceMember(suite.DB(), nil, nil) + customers := models.ServiceMembers{customer} + + mockSearcher := mocks.CustomerSearcher{} + + handler := SearchCustomersHandler{ + HandlerConfig: suite.HandlerConfig(), + CustomerSearcher: &mockSearcher, + } + mockSearcher.On("SearchCustomers", + mock.AnythingOfType("*appcontext.appContext"), + mock.MatchedBy(func(params *services.SearchCustomersParams) bool { + return *params.CustomerName == *customer.FirstName && + params.DodID == nil + }), + ).Return(customers, 1, nil) + + params := customerops.SearchCustomersParams{ + HTTPRequest: req, + Body: customerops.SearchCustomersBody{ + CustomerName: customer.FirstName, + }, + } + + suite.NoError(params.Body.Validate(strfmt.Default)) + response := handler.Handle(params) + suite.IsType(&customerops.SearchCustomersOK{}, response) + payload := response.(*customerops.SearchCustomersOK).Payload + suite.NoError(payload.Validate(strfmt.Default)) + + suite.Equal(customer.FirstName, (*payload).SearchCustomers[0].FirstName) + }) +} + // Generate and activate Okta endpoints that will be using during the auth handlers. func mockAndActivateOktaEndpoints(provider *okta.Provider) { diff --git a/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go b/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go index 5cc975934b6..44fb2752872 100644 --- a/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go +++ b/pkg/handlers/ghcapi/internal/payloads/model_to_payload.go @@ -2012,3 +2012,19 @@ func ShipmentsPaymentSITBalance(shipmentsSITBalance []services.ShipmentPaymentSI return payload } + +func SearchCustomers(customers models.ServiceMembers) *ghcmessages.SearchCustomers { + searchCustomers := make(ghcmessages.SearchCustomers, len(customers)) + for i, customer := range customers { + searchCustomers[i] = &ghcmessages.SearchCustomer{ + FirstName: customer.FirstName, + LastName: customer.LastName, + DodID: customer.Edipi, + Branch: customer.Affiliation.String(), + ID: *handlers.FmtUUID(customer.ID), + PersonalEmail: *customer.PersonalEmail, + Telephone: customer.Telephone, + } + } + return &searchCustomers +} diff --git a/pkg/services/customer.go b/pkg/services/customer.go index ce563908baf..00883caf94a 100644 --- a/pkg/services/customer.go +++ b/pkg/services/customer.go @@ -20,3 +20,17 @@ type CustomerFetcher interface { type CustomerUpdater interface { UpdateCustomer(appCtx appcontext.AppContext, eTag string, customer models.ServiceMember) (*models.ServiceMember, error) } + +//go:generate mockery --name CustomerSearcher +type CustomerSearcher interface { + SearchCustomers(appCtx appcontext.AppContext, params *SearchCustomersParams) (models.ServiceMembers, int, error) +} + +type SearchCustomersParams struct { + DodID *string + CustomerName *string + Page int64 + PerPage int64 + Sort *string + Order *string +} diff --git a/pkg/services/mocks/CustomerSearcher.go b/pkg/services/mocks/CustomerSearcher.go new file mode 100644 index 00000000000..916c7534e1d --- /dev/null +++ b/pkg/services/mocks/CustomerSearcher.go @@ -0,0 +1,64 @@ +// Code generated by mockery. DO NOT EDIT. + +package mocks + +import ( + mock "github.com/stretchr/testify/mock" + appcontext "github.com/transcom/mymove/pkg/appcontext" + + models "github.com/transcom/mymove/pkg/models" + + services "github.com/transcom/mymove/pkg/services" +) + +// CustomerSearcher is an autogenerated mock type for the CustomerSearcher type +type CustomerSearcher struct { + mock.Mock +} + +// SearchCustomers provides a mock function with given fields: appCtx, params +func (_m *CustomerSearcher) SearchCustomers(appCtx appcontext.AppContext, params *services.SearchCustomersParams) (models.ServiceMembers, int, error) { + ret := _m.Called(appCtx, params) + + var r0 models.ServiceMembers + var r1 int + var r2 error + if rf, ok := ret.Get(0).(func(appcontext.AppContext, *services.SearchCustomersParams) (models.ServiceMembers, int, error)); ok { + return rf(appCtx, params) + } + if rf, ok := ret.Get(0).(func(appcontext.AppContext, *services.SearchCustomersParams) models.ServiceMembers); ok { + r0 = rf(appCtx, params) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(models.ServiceMembers) + } + } + + if rf, ok := ret.Get(1).(func(appcontext.AppContext, *services.SearchCustomersParams) int); ok { + r1 = rf(appCtx, params) + } else { + r1 = ret.Get(1).(int) + } + + if rf, ok := ret.Get(2).(func(appcontext.AppContext, *services.SearchCustomersParams) error); ok { + r2 = rf(appCtx, params) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// NewCustomerSearcher creates a new instance of CustomerSearcher. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewCustomerSearcher(t interface { + mock.TestingT + Cleanup(func()) +}) *CustomerSearcher { + mock := &CustomerSearcher{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/services/office_user/customer/customer_searcher.go b/pkg/services/office_user/customer/customer_searcher.go new file mode 100644 index 00000000000..82e3b812202 --- /dev/null +++ b/pkg/services/office_user/customer/customer_searcher.go @@ -0,0 +1,105 @@ +package customer + +import ( + "fmt" + + "github.com/gobuffalo/pop/v6" + "github.com/gobuffalo/validate/v3" + "github.com/gofrs/uuid" + + "github.com/transcom/mymove/pkg/appcontext" + "github.com/transcom/mymove/pkg/apperror" + "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/models/roles" + "github.com/transcom/mymove/pkg/services" +) + +type customerSearcher struct { +} + +func NewCustomerSearcher() services.CustomerSearcher { + return &customerSearcher{} +} + +type QueryOption func(*pop.Query) + +func (s customerSearcher) SearchCustomers(appCtx appcontext.AppContext, params *services.SearchCustomersParams) (models.ServiceMembers, int, error) { + if params.DodID == nil && params.CustomerName == nil { + verrs := validate.NewErrors() + verrs.Add("search key", "DOD ID or customer name must be provided") + return models.ServiceMembers{}, 0, apperror.NewInvalidInputError(uuid.Nil, nil, verrs, "") + } + + if params.CustomerName != nil && params.DodID != nil { + verrs := validate.NewErrors() + verrs.Add("search key", "search by multiple keys is not supported") + return models.ServiceMembers{}, 0, apperror.NewInvalidInputError(uuid.Nil, nil, verrs, "") + } + + err := appCtx.DB().RawQuery("SET pg_trgm.similarity_threshold = 0.1").Exec() + if err != nil { + return nil, 0, err + } + + var query *pop.Query + + if appCtx.Session().Roles.HasRole(roles.RoleTypeServicesCounselor) { + query = appCtx.DB().Q(). + Join("users", "users.id = service_members.user_id") + } + + customerNameQuery := customerNameSearch(params.CustomerName) + dodIDQuery := dodIDSearch(params.DodID) + orderQuery := sortOrder(params.Sort, params.Order) + + options := [3]QueryOption{customerNameQuery, dodIDQuery, orderQuery} + + for _, option := range options { + if option != nil { + option(query) + } + } + + var customers models.ServiceMembers + err = query.Paginate(int(params.Page), int(params.PerPage)).All(&customers) + + if err != nil { + return models.ServiceMembers{}, 0, apperror.NewQueryError("Customer", err, "") + } + return customers, query.Paginator.TotalEntriesSize, nil +} + +func dodIDSearch(dodID *string) QueryOption { + return func(query *pop.Query) { + if dodID != nil { + query.Where("service_members.edipi = ?", dodID) + } + } +} + +func customerNameSearch(customerName *string) QueryOption { + return func(query *pop.Query) { + if customerName != nil && len(*customerName) > 0 { + query.Where("f_unaccent(lower(?)) % searchable_full_name(first_name, last_name)", *customerName) + } + } +} + +var parameters = map[string]string{ + "customerName": "service_members.last_name", + "dodID": "service_members.edipi", + "branch": "service_members.affiliation", + "personalEmail": "service_members.personal_email", + "telephone": "service_members.telephone", +} + +func sortOrder(sort *string, order *string) QueryOption { + return func(query *pop.Query) { + if sort != nil && order != nil { + sortTerm := parameters[*sort] + query.Order(fmt.Sprintf("%s %s", sortTerm, *order)) + } else { + query.Order("service_members.last_name ASC") + } + } +} diff --git a/pkg/services/office_user/customer/customer_searcher_test.go b/pkg/services/office_user/customer/customer_searcher_test.go new file mode 100644 index 00000000000..519b7f25c3a --- /dev/null +++ b/pkg/services/office_user/customer/customer_searcher_test.go @@ -0,0 +1,183 @@ +package customer + +import ( + "github.com/transcom/mymove/pkg/auth" + "github.com/transcom/mymove/pkg/factory" + "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/models/roles" + "github.com/transcom/mymove/pkg/services" +) + +func (suite CustomerServiceSuite) TestCustomerSearch() { + searcher := NewCustomerSearcher() + + suite.Run("search with no filters should fail", func() { + scUser := factory.BuildOfficeUserWithRoles(suite.DB(), nil, []roles.RoleType{roles.RoleTypeServicesCounselor}) + session := auth.Session{ + ApplicationName: auth.OfficeApp, + Roles: scUser.User.Roles, + OfficeUserID: scUser.ID, + IDToken: "fake_token", + AccessToken: "fakeAccessToken", + } + + factory.BuildServiceMember(suite.DB(), []factory.Customization{ + { + Model: models.ServiceMember{ + FirstName: models.StringPointer("Trey"), + LastName: models.StringPointer("Anastasio"), + Edipi: models.StringPointer("6191061910"), + }, + }, + }, nil) + + _, _, err := searcher.SearchCustomers(suite.AppContextWithSessionForTest(&session), &services.SearchCustomersParams{}) + suite.Error(err) + }) + + suite.Run("search with a valid DOD ID", func() { + scUser := factory.BuildOfficeUserWithRoles(suite.DB(), nil, []roles.RoleType{roles.RoleTypeServicesCounselor}) + session := auth.Session{ + ApplicationName: auth.OfficeApp, + Roles: scUser.User.Roles, + OfficeUserID: scUser.ID, + IDToken: "fake_token", + AccessToken: "fakeAccessToken", + } + + serviceMember1 := factory.BuildServiceMember(suite.DB(), []factory.Customization{ + { + Model: models.ServiceMember{ + FirstName: models.StringPointer("Mike"), + LastName: models.StringPointer("Gordon"), + Edipi: models.StringPointer("8121581215"), + }, + }, + }, nil) + + customers, _, err := searcher.SearchCustomers(suite.AppContextWithSessionForTest(&session), &services.SearchCustomersParams{DodID: serviceMember1.Edipi}) + suite.NoError(err) + suite.Len(customers, 1) + suite.Equal(serviceMember1.Edipi, customers[0].Edipi) + }) + + suite.Run("search with a customer name", func() { + scUser := factory.BuildOfficeUserWithRoles(suite.DB(), nil, []roles.RoleType{roles.RoleTypeServicesCounselor}) + session := auth.Session{ + ApplicationName: auth.OfficeApp, + Roles: scUser.User.Roles, + OfficeUserID: scUser.ID, + IDToken: "fake_token", + AccessToken: "fakeAccessToken", + } + + serviceMember1 := factory.BuildServiceMember(suite.DB(), []factory.Customization{ + { + Model: models.ServiceMember{ + FirstName: models.StringPointer("Page"), + LastName: models.StringPointer("McConnell"), + Edipi: models.StringPointer("1018231018"), + }, + }, + }, nil) + + customers, _, err := searcher.SearchCustomers(suite.AppContextWithSessionForTest(&session), &services.SearchCustomersParams{CustomerName: models.StringPointer("Page McConnel")}) + suite.NoError(err) + suite.Len(customers, 1) + suite.Equal(serviceMember1.Edipi, customers[0].Edipi) + }) + + suite.Run("search with both DOD ID and name should fail", func() { + scUser := factory.BuildOfficeUserWithRoles(suite.DB(), nil, []roles.RoleType{roles.RoleTypeServicesCounselor}) + session := auth.Session{ + ApplicationName: auth.OfficeApp, + Roles: scUser.User.Roles, + OfficeUserID: scUser.ID, + IDToken: "fake_token", + AccessToken: "fakeAccessToken", + } + + serviceMember1 := factory.BuildServiceMember(suite.DB(), []factory.Customization{ + { + Model: models.ServiceMember{ + FirstName: models.StringPointer("Page"), + LastName: models.StringPointer("McConnell"), + Edipi: models.StringPointer("1018231018"), + }, + }, + }, nil) + + _, _, err := searcher.SearchCustomers(suite.AppContextWithSessionForTest(&session), &services.SearchCustomersParams{ + DodID: serviceMember1.Edipi, + CustomerName: models.StringPointer("Page McConnel"), + }) + suite.Error(err) + }) + + suite.Run("search with no results", func() { + scUser := factory.BuildOfficeUserWithRoles(suite.DB(), nil, []roles.RoleType{roles.RoleTypeServicesCounselor}) + session := auth.Session{ + ApplicationName: auth.OfficeApp, + Roles: scUser.User.Roles, + OfficeUserID: scUser.ID, + IDToken: "fake_token", + AccessToken: "fakeAccessToken", + } + + customers, _, err := searcher.SearchCustomers(suite.AppContextWithSessionForTest(&session), &services.SearchCustomersParams{CustomerName: models.StringPointer("Jon Fishman")}) + suite.NoError(err) + suite.Len(customers, 0) + }) + + suite.Run("test pagination", func() { + scUser := factory.BuildOfficeUserWithRoles(suite.DB(), nil, []roles.RoleType{roles.RoleTypeServicesCounselor}) + session := auth.Session{ + ApplicationName: auth.OfficeApp, + Roles: scUser.User.Roles, + OfficeUserID: scUser.ID, + IDToken: "fake_token", + AccessToken: "fakeAccessToken", + } + + serviceMember1 := factory.BuildServiceMember(suite.DB(), []factory.Customization{ + { + Model: models.ServiceMember{ + FirstName: models.StringPointer("Page"), + LastName: models.StringPointer("McConnell"), + Edipi: models.StringPointer("1018231018"), + }, + }, + }, nil) + + serviceMember2 := factory.BuildServiceMember(suite.DB(), []factory.Customization{ + { + Model: models.ServiceMember{ + FirstName: models.StringPointer("Page"), + LastName: models.StringPointer("McConnell"), + Edipi: models.StringPointer("8121581215"), + }, + }, + }, nil) + // get first page + customers, totalCount, err := searcher.SearchCustomers(suite.AppContextWithSessionForTest(&session), &services.SearchCustomersParams{ + CustomerName: models.StringPointer("Page McConnell"), + PerPage: 1, + Page: 1, + }) + suite.NoError(err) + suite.Len(customers, 1) + suite.Equal(serviceMember1.Edipi, customers[0].Edipi) + suite.Equal(2, totalCount) + + // get second page + customers, totalCount, err = searcher.SearchCustomers(suite.AppContextWithSessionForTest(&session), &services.SearchCustomersParams{ + CustomerName: models.StringPointer("Page McConnell"), + PerPage: 1, + Page: 2, + }) + suite.NoError(err) + suite.Len(customers, 1) + suite.Equal(serviceMember2.Edipi, customers[0].Edipi) + suite.Equal(2, totalCount) + }) +} diff --git a/src/components/CustomerSearchForm/CustomerSearchForm.jsx b/src/components/CustomerSearchForm/CustomerSearchForm.jsx new file mode 100644 index 00000000000..af7c05f1d33 --- /dev/null +++ b/src/components/CustomerSearchForm/CustomerSearchForm.jsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { Field, Formik } from 'formik'; +import classnames from 'classnames'; +import { Button, Radio } from '@trussworks/react-uswds'; +import * as Yup from 'yup'; +import PropTypes from 'prop-types'; + +import styles from './CustomerSearchForm.module.scss'; + +import { Form } from 'components/form/Form'; +import TextField from 'components/form/fields/TextField/TextField'; +import formStyles from 'styles/form.module.scss'; + +const baseSchema = Yup.object().shape({ + searchType: Yup.string().required('Select either DOD ID or Customer Name'), +}); +const dodIDSchema = baseSchema.concat( + Yup.object().shape({ + searchText: Yup.string().trim().length(10, 'DOD ID must be exactly 10 digits'), + }), +); +const customerNameSchema = baseSchema.concat( + Yup.object().shape({ + searchText: Yup.string().trim().min(1, 'Customer search must contain a value'), + }), +); + +const CustomerSearchForm = ({ onSubmit }) => { + const getValidationSchema = (values) => { + switch (values.searchType) { + case 'dodID': + return dodIDSchema; + case 'customerName': + return customerNameSchema; + default: + return Yup.object().shape({ + searchType: Yup.string().required('Search option must be selected'), + searchText: Yup.string().required('Required'), + }); + } + }; + return ( + { + const schema = getValidationSchema(values); + try { + schema.validateSync(values, { abortEarly: false }); + } catch (error) { + return error.inner.reduce((acc, { path, message }) => ({ ...acc, [path]: message }), {}); + } + }} + > + {(formik) => { + return ( +
+ What do you want to search for? +
+ { + formik.setFieldValue('searchType', e.target.value); + formik.setFieldValue('searchText', '', false); // Clear TextField + formik.setFieldTouched('searchText', false, false); + }} + /> + { + formik.setFieldValue('searchType', e.target.value); + formik.setFieldValue('searchText', '', false); // Clear TextField + formik.setFieldTouched('searchText', false, false); + }} + /> +
+
+ Search} + name="searchText" + type="search" + button={ + + } + /> +
+
+ ); + }} +
+ ); +}; + +CustomerSearchForm.propTypes = { + onSubmit: PropTypes.func.isRequired, +}; + +export default CustomerSearchForm; diff --git a/src/components/CustomerSearchForm/CustomerSearchForm.module.scss b/src/components/CustomerSearchForm/CustomerSearchForm.module.scss new file mode 100644 index 00000000000..36e260cd068 --- /dev/null +++ b/src/components/CustomerSearchForm/CustomerSearchForm.module.scss @@ -0,0 +1,60 @@ +@import 'shared/styles/basics'; +@import 'shared/styles/colors'; +@import 'styles/office.scss'; + +.CustomerSearchForm { + @include u-padding-y(3); + @include u-padding-x(4); + @include u-bg('white'); + @include u-radius(4px); + border: 1px solid $border-color; + max-width: none !important; + + :global(.usa-radio) { + @include u-margin-left(0); + @include u-margin-right(2); + } + :global(.usa-radio__label) { + font-size: 15px; + line-height: 23px; + } + :global(.usa-radio__input) + :global(.usa-radio__label::before) { + box-shadow: 0 0 0 2px $base, inset 0 0 0 2px white; + } + :global(.usa-radio__input:checked) + :global(.usa-radio__label::before) { + background-color: $link; + box-shadow: 0 0 0 2px $link, inset 0 0 0 2px white; + } + + legend { + @include u-margin(0); + } + + .searchBar { + @include u-margin-top(2); + display: flex; + @include u-margin-top(2); + :global(.usa-form-group) { + @include u-margin(0); + } + legend { + @include u-margin-bottom(1); + } + } + + .searchButton { + height: 43px; + width: 99px; + border-bottom-left-radius: 0; + border-top-left-radius: 0; + @include u-margin(0 !important); + background-color: $link; + } + .searchButton:hover { + background-color: $primary-dark; + } + :global(.usa-search__input) { + height: 43px; + width: 356px; + } +} \ No newline at end of file diff --git a/src/components/CustomerSearchForm/CustomerSearchForm.test.jsx b/src/components/CustomerSearchForm/CustomerSearchForm.test.jsx new file mode 100644 index 00000000000..518e2b8cf63 --- /dev/null +++ b/src/components/CustomerSearchForm/CustomerSearchForm.test.jsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import CustomerSearchForm from './CustomerSearchForm'; + +import { searchCustomers } from 'services/ghcApi'; + +jest.mock('services/ghcApi', () => ({ + ...jest.requireActual('services/ghcApi'), + searchCustomers: jest.fn(), +})); + +beforeEach(jest.resetAllMocks); + +describe('CustomerSearchForm', () => { + it('renders', () => { + const { getByText } = render( {}} />); + expect(getByText('What do you want to search for?')).toBeInTheDocument(); + }); + + describe('check validation', () => { + it('can submit DOD ID', async () => { + const onSubmit = searchCustomers; + const { getByLabelText, getByRole } = render(); + const submitButton = getByRole('button'); + + await userEvent.click(getByLabelText('DOD ID')); + await userEvent.type(getByLabelText('Search'), '4152341523'); + await waitFor(() => { + expect(getByLabelText('Search')).toHaveValue('4152341523'); + expect(getByLabelText('DOD ID')).toBeChecked(); + }); + expect(submitButton).toBeEnabled(); + await userEvent.click(submitButton); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { + searchText: '4152341523', + searchType: 'dodID', + }, + expect.anything(), + ); + }); + }); + + it('can submit name', async () => { + const onSubmit = searchCustomers; + const { getByLabelText, getByRole } = render(); + const submitButton = getByRole('button'); + + await userEvent.click(getByLabelText('Customer Name')); + await userEvent.type(getByLabelText('Search'), 'Leo Spaceman'); + await waitFor(() => { + expect(getByLabelText('Search')).toHaveValue('Leo Spaceman'); + }); + expect(submitButton).toBeEnabled(); + await userEvent.click(submitButton); + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + { + searchText: 'Leo Spaceman', + searchType: 'customerName', + }, + expect.anything(), + ); + }); + }); + }); +}); diff --git a/src/components/Office/AddOrdersForm/AddOrdersForm.jsx b/src/components/Office/AddOrdersForm/AddOrdersForm.jsx index 48af3d182f0..3674d3ea3ec 100644 --- a/src/components/Office/AddOrdersForm/AddOrdersForm.jsx +++ b/src/components/Office/AddOrdersForm/AddOrdersForm.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { Field, Formik } from 'formik'; import * as Yup from 'yup'; -import { FormGroup, Label, Radio } from '@trussworks/react-uswds'; +import { FormGroup, Label, Radio, Link as USWDSLink } from '@trussworks/react-uswds'; import { DatePickerInput, DropdownInput, DutyLocationInput } from 'components/form/fields'; import { Form } from 'components/form/Form'; @@ -10,6 +10,7 @@ import SectionWrapper from 'components/Customer/SectionWrapper'; import { ORDERS_PAY_GRADE_OPTIONS } from 'constants/orders'; import { dropdownInputOptions } from 'utils/formatters'; import WizardNavigation from 'components/Customer/WizardNavigation/WizardNavigation'; +import Callout from 'components/Callout'; const AddOrdersForm = ({ onSubmit, ordersTypeOptions, initialValues, onBack }) => { const payGradeOptions = dropdownInputOptions(ORDERS_PAY_GRADE_OPTIONS); @@ -32,7 +33,8 @@ const AddOrdersForm = ({ onSubmit, ordersTypeOptions, initialValues, onBack }) = return ( - {({ isValid, isSubmitting, handleSubmit }) => { + {({ values, isValid, isSubmitting, handleSubmit }) => { + const isRetirementOrSeparation = ['RETIREMENT', 'SEPARATION'].includes(values.ordersType); return (

Tell us about the orders

@@ -73,7 +75,40 @@ const AddOrdersForm = ({ onSubmit, ordersTypeOptions, initialValues, onBack }) = id="originDutyLocation" required /> - + + {isRetirementOrSeparation ? ( + <> +

Where are they entitled to move?

+ + The government will pay for their move to: +
    +
  • Home of record (HOR)
  • +
  • Place entered active duty (PLEAD)
  • +
+

+ It might pay for a move to their Home of selection (HOS), anywhere in CONUS. Check their orders. +

+

+ Read more about where they are entitled to move when leaving the military on{' '} + + Military OneSource. + +

+
+ + + ) : ( + + )} diff --git a/src/components/Table/SearchResultsTable.jsx b/src/components/Table/SearchResultsTable.jsx index 3e1055f82b8..177874c3a57 100644 --- a/src/components/Table/SearchResultsTable.jsx +++ b/src/components/Table/SearchResultsTable.jsx @@ -17,11 +17,10 @@ import { DATE_FORMAT_STRING } from 'shared/constants'; import { formatDateFromIso, serviceMemberAgencyLabel } from 'utils/formatters'; import MultiSelectCheckBoxFilter from 'components/Table/Filters/MultiSelectCheckBoxFilter'; import SelectFilter from 'components/Table/Filters/SelectFilter'; -import { roleTypes } from 'constants/userRoles'; import { servicesCounselingRoutes } from 'constants/routes'; import { CHECK_SPECIAL_ORDERS_TYPES, SPECIAL_ORDERS_TYPES } from 'constants/orders'; -const columns = () => [ +const moveSearchColumns = () => [ createHeader('Move code', 'locator', { id: 'locator', isFilterable: false, @@ -155,7 +154,7 @@ const columns = () => [ ), ]; -const columnsWithCreateMove = () => [ +const customerSearchColumns = () => [ createHeader( 'Create Move', (row) => { @@ -172,16 +171,18 @@ const columnsWithCreateMove = () => [ ); }, - { isFilterable: false }, + { isFilterable: false, disableSortBy: true }, + ), + createHeader( + 'id', + (row) => { + return row.id; + }, + { + id: 'customerID', + isFilterable: false, + }, ), - createHeader('Move code', 'locator', { - id: 'locator', - isFilterable: false, - }), - createHeader('DOD ID', 'dodID', { - id: 'dodID', - isFilterable: false, - }), createHeader( 'Customer name', (row) => { @@ -199,25 +200,6 @@ const columnsWithCreateMove = () => [ isFilterable: false, }, ), - createHeader( - 'Status', - (row) => { - return MOVE_STATUS_LABELS[`${row.status}`]; - }, - { - id: 'status', - isFilterable: true, - Filter: (props) => { - return ( - - ); - }, - }, - ), createHeader( 'Branch', (row) => { @@ -225,86 +207,21 @@ const columnsWithCreateMove = () => [ }, { id: 'branch', - isFilterable: true, - Filter: (props) => ( - // eslint-disable-next-line react/jsx-props-no-spreading - - ), - }, - ), - createHeader( - 'Number of Shipments', - (row) => { - return Number(row.shipmentsCount || 0); - }, - { id: 'shipmentsCount', isFilterable: true }, - ), - createHeader( - 'Scheduled Pickup Date', - (row) => { - return formatDateFromIso(row.requestedPickupDate, DATE_FORMAT_STRING); - }, - { - id: 'pickupDate', - disableSortBy: true, - isFilterable: true, - // eslint-disable-next-line react/jsx-props-no-spreading - Filter: (props) => , - }, - ), - createHeader( - 'Origin ZIP', - (row) => { - return row.originDutyLocationPostalCode; - }, - { - id: 'originPostalCode', - isFilterable: true, - }, - ), - createHeader( - 'Origin GBLOC', - (row) => { - return row.originGBLOC; - }, - { - id: 'originGBLOC', - disableSortBy: true, - }, - ), - createHeader( - 'Scheduled Delivery Date', - (row) => { - return formatDateFromIso(row.requestedDeliveryDate, DATE_FORMAT_STRING); - }, - { - id: 'deliveryDate', - disableSortBy: true, - isFilterable: true, - // eslint-disable-next-line react/jsx-props-no-spreading - Filter: (props) => , - }, - ), - createHeader( - 'Destination ZIP', - (row) => { - return row.destinationDutyLocationPostalCode; - }, - { - id: 'destinationPostalCode', - isFilterable: true, - }, - ), - createHeader( - 'Destination GBLOC', - (row) => { - return row.destinationGBLOC; - }, - { - id: 'destinationGBLOC', - disableSortBy: true, + isFilterable: false, }, ), + createHeader('DOD ID', 'dodID', { + id: 'dodID', + isFilterable: false, + }), + createHeader('Email', 'personalEmail', { + id: 'personalEmail', + isFilterable: false, + }), + createHeader('Phone', 'telephone', { + id: 'telephone', + isFilterable: false, + }), ]; // SearchResultsTable is a react-table that uses react-hooks to fetch, filter, sort and page data @@ -323,8 +240,7 @@ const SearchResultsTable = (props) => { dodID, moveCode, customerName, - roleType, - isCounselorMoveCreateFFEnabled, + searchType, } = props; const [paramSort, setParamSort] = useState(defaultSortedColumns); const [paramFilters, setParamFilters] = useState([]); @@ -361,10 +277,9 @@ const SearchResultsTable = (props) => { ); const tableData = useMemo(() => data, [data]); - const tableColumns = useMemo( - () => (isCounselorMoveCreateFFEnabled ? columnsWithCreateMove(roleType) : columns(roleType)), - [roleType, isCounselorMoveCreateFFEnabled], - ); + const tableColumns = useMemo(() => { + return searchType === 'customer' ? customerSearchColumns() : moveSearchColumns(); + }, [searchType]); const { getTableProps, @@ -495,8 +410,7 @@ SearchResultsTable.propTypes = { moveCode: PropTypes.string, // customerName is the customer name search text customerName: PropTypes.string, - roleType: PropTypes.string, - isCounselorMoveCreateFFEnabled: PropTypes.bool, + searchType: PropTypes.string, }; SearchResultsTable.defaultProps = { @@ -510,8 +424,7 @@ SearchResultsTable.defaultProps = { dodID: null, moveCode: null, customerName: null, - roleType: roleTypes.QAE_CSR, - isCounselorMoveCreateFFEnabled: false, + searchType: 'move', }; export default SearchResultsTable; diff --git a/src/components/Table/SearchResultsTable.test.jsx b/src/components/Table/SearchResultsTable.test.jsx index a6ef451f375..0911d4119ce 100644 --- a/src/components/Table/SearchResultsTable.test.jsx +++ b/src/components/Table/SearchResultsTable.test.jsx @@ -3,8 +3,6 @@ import { render, screen } from '@testing-library/react'; import SearchResultsTable from './SearchResultsTable'; -import { roleTypes } from 'constants/userRoles'; - const mockTableData = [ { branch: 'ARMY', @@ -24,6 +22,18 @@ const mockTableData = [ }, ]; +const mockCustomerTableData = [ + { + branch: 'MARINES', + dodID: '6585626513', + firstName: 'Ted', + id: '8604447b-cbfc-4d59-a9a1-dec219eb2046', + lastName: 'Marine', + personalEmail: 'leo_spaceman_sm@example.com', + telephone: '212-123-4567', + }, +]; + function mockQueries() { return { searchResult: { @@ -35,6 +45,17 @@ function mockQueries() { isSuccess: true, }; } +function mockCustomerQueries() { + return { + searchResult: { + data: mockCustomerTableData, + totalCount: mockCustomerTableData.length, + }, + isLoading: false, + isError: false, + isSuccess: true, + }; +} function mockLoadingQuery() { return { searchResult: { @@ -59,7 +80,7 @@ function mockErrorQuery() { } describe('SearchResultsTable', () => { - it('renders', () => { + it('renders a move search', () => { render( {}} title="Results" useQueries={mockQueries} />); const results = screen.queryByText('Results (1)'); expect(results).toBeInTheDocument(); @@ -74,20 +95,42 @@ describe('SearchResultsTable', () => { const destinationGBLOC = screen.queryByText('CNNQ'); expect(destinationGBLOC).toBeInTheDocument(); }); - it('renders create move button when logged in as SC and FF is enabled', () => { + it('renders a customer search', () => { render( {}} title="Results" - useQueries={mockQueries} - roleType={roleTypes.SERVICES_COUNSELOR} - isCounselorMoveCreateFFEnabled + useQueries={mockCustomerQueries} + searchType="customer" />, ); + const results = screen.queryByText('Results (1)'); + expect(results).toBeInTheDocument(); + const branch = screen.queryByText('Marine Corps'); + expect(branch).toBeInTheDocument(); + const dodID = screen.queryByText('6585626513'); + expect(dodID).toBeInTheDocument(); + const name = screen.queryByText('Marine, Ted'); + expect(name).toBeInTheDocument(); + const email = screen.queryByText('leo_spaceman_sm@example.com'); + expect(email).toBeInTheDocument(); + const phone = screen.queryByText('212-123-4567'); + expect(phone).toBeInTheDocument(); + }); + it('renders create move button on customer search', () => { + render( + {}} title="Results" useQueries={mockQueries} searchType="customer" />, + ); const createMoveButton = screen.queryByTestId('searchCreateMoveButton'); expect(createMoveButton).toBeInTheDocument(); }); + it('does not render create move button on move search', () => { + render( {}} title="Results" useQueries={mockQueries} searchType="move" />); + + const createMoveButton = screen.queryByTestId('searchCreateMoveButton'); + expect(createMoveButton).not.toBeInTheDocument(); + }); it('loading', () => { render( {}} title="Results" useQueries={mockLoadingQuery} dodID="1234567890" />, diff --git a/src/constants/queryKeys.js b/src/constants/queryKeys.js index 23f69e91fe2..b30b807a3b4 100644 --- a/src/constants/queryKeys.js +++ b/src/constants/queryKeys.js @@ -26,3 +26,4 @@ export const REPORT_VIOLATIONS = 'reportViolations'; export const DOCUMENTS = 'documents'; export const PPMCLOSEOUT = 'ppmCloseout'; export const PPMACTUALWEIGHT = 'ppmActualWeight'; +export const SC_CUSTOMER_SEARCH = 'scCustomerSearch'; diff --git a/src/constants/routes.js b/src/constants/routes.js index 34d168239b4..f3afb3eec45 100644 --- a/src/constants/routes.js +++ b/src/constants/routes.js @@ -50,6 +50,7 @@ export const customerRoutes = { }; const BASE_COUNSELING_MOVE_PATH = '/counseling/moves/:moveCode'; +const BASE_COUNSELING_CUSTOMER_PATH = '/counseling/customers/:customerId'; export const servicesCounselingRoutes = { BASE_QUEUE_VIEW_PATH: '/counseling/queue', @@ -59,6 +60,8 @@ export const servicesCounselingRoutes = { BASE_QUEUE_COUNSELING_PATH: '/counseling', QUEUE_CLOSEOUT_PATH: 'PPM-closeout', BASE_QUEUE_CLOSEOUT_PATH: '/PPM-closeout', + CUSTOMER_SEARCH_PATH: 'customer-search', + BASE_CUSTOMER_SEARCH_PATH: '/customer-search', BASE_COUNSELING_MOVE_PATH, BASE_ALLOWANCES_EDIT_PATH: `${BASE_COUNSELING_MOVE_PATH}/allowances`, ALLOWANCES_EDIT_PATH: 'allowances', @@ -66,8 +69,6 @@ export const servicesCounselingRoutes = { CUSTOMER_INFO_EDIT_PATH: 'customer', BASE_MOVE_VIEW_PATH: `${BASE_COUNSELING_MOVE_PATH}/details`, MOVE_VIEW_PATH: 'details', - BASE_ORDERS_ADD_PATH: `${BASE_COUNSELING_MOVE_PATH}/new-orders`, - ORDERS_ADD_PATH: 'new-orders', BASE_CREATE_MOVE_EDIT_CUSTOMER_PATH: `${BASE_COUNSELING_MOVE_PATH}/edit-customer`, CREATE_MOVE_EDIT_CUSTOMER_PATH: 'edit-customer', BASE_ORDERS_EDIT_PATH: `${BASE_COUNSELING_MOVE_PATH}/orders`, @@ -89,6 +90,11 @@ export const servicesCounselingRoutes = { BASE_REVIEW_SHIPMENT_WEIGHTS_PATH: `${BASE_COUNSELING_MOVE_PATH}/review-shipment-weights`, REVIEW_SHIPMENT_WEIGHTS_PATH: 'review-shipment-weights', CREATE_CUSTOMER_PATH: '/onboarding/create-customer', + BASE_CUSTOMERS_CUSTOMER_INFO_PATH: `${BASE_COUNSELING_CUSTOMER_PATH}/customer-info`, + CUSTOMERS_CUSTOMER_INFO_PATH: 'customer-info', + BASE_CUSTOMERS_ORDERS_ADD_PATH: `${BASE_COUNSELING_CUSTOMER_PATH}/new-orders`, + CUSTOMERS_ORDERS_ADD_PATH: 'new-orders', + CREATE_MOVE_CUSTOMER_INFO_PATH: '/create-move/customer-info', }; const BASE_MOVES_PATH = '/moves/:moveCode'; diff --git a/src/hooks/queries.js b/src/hooks/queries.js index fe6a162f7a4..28c65e01d0d 100644 --- a/src/hooks/queries.js +++ b/src/hooks/queries.js @@ -31,6 +31,7 @@ import { getPrimeSimulatorAvailableMoves, getPPMCloseout, getPPMActualWeight, + searchCustomers, } from 'services/ghcApi'; import { getLoggedInUserQueries } from 'services/internalApi'; import { getPrimeSimulatorMove } from 'services/primeApi'; @@ -63,6 +64,7 @@ import { PRIME_SIMULATOR_AVAILABLE_MOVES, PPMCLOSEOUT, PPMACTUALWEIGHT, + SC_CUSTOMER_SEARCH, } from 'constants/queryKeys'; import { PAGINATION_PAGE_DEFAULT, PAGINATION_PAGE_SIZE_DEFAULT } from 'constants/queues'; @@ -875,3 +877,46 @@ export const useMoveSearchQueries = ({ isSuccess, }; }; + +export const useCustomerSearchQueries = ({ + sort, + order, + filters = [], + currentPage = PAGINATION_PAGE_DEFAULT, + currentPageSize = PAGINATION_PAGE_SIZE_DEFAULT, +}) => { + const queryResult = useQuery( + [SC_CUSTOMER_SEARCH, { sort, order, filters, currentPage, currentPageSize }], + ({ queryKey }) => searchCustomers(...queryKey), + { + enabled: filters.length > 0, + }, + ); + const { data = {}, ...customerSearchQuery } = queryResult; + const { isLoading, isError, isSuccess } = getQueriesStatus([customerSearchQuery]); + const searchCustomersResult = data.searchCustomers; + return { + searchResult: { data: searchCustomersResult, page: data.page, perPage: data.perPage, totalCount: data.totalCount }, + isLoading, + isError, + isSuccess, + }; +}; + +export const useCustomerQuery = (customerId) => { + const { data: { customer } = {}, ...customerQuery } = useQuery( + [CUSTOMER, customerId], + ({ queryKey }) => getCustomer(...queryKey), + { + enabled: !!customerId, + }, + ); + const customerData = customer && Object.values(customer)[0]; + const { isLoading, isError, isSuccess } = getQueriesStatus([customerQuery]); + return { + customerData, + isLoading, + isError, + isSuccess, + }; +}; diff --git a/src/pages/Office/CreateMoveCustomerInfo/CreateMoveCustomerInfo.jsx b/src/pages/Office/CreateMoveCustomerInfo/CreateMoveCustomerInfo.jsx index c8ef1713745..32caa94de80 100644 --- a/src/pages/Office/CreateMoveCustomerInfo/CreateMoveCustomerInfo.jsx +++ b/src/pages/Office/CreateMoveCustomerInfo/CreateMoveCustomerInfo.jsx @@ -1,25 +1,27 @@ import React from 'react'; -import PropTypes from 'prop-types'; import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { useNavigate } from 'react-router'; +import { generatePath, useNavigate, useParams } from 'react-router'; import { GridContainer } from '@trussworks/react-uswds'; import CustomerContactInfoForm from 'components/Office/CustomerContactInfoForm/CustomerContactInfoForm'; -import { CUSTOMER, ORDERS } from 'constants/queryKeys'; +import { CUSTOMER } from 'constants/queryKeys'; import { servicesCounselingRoutes } from 'constants/routes'; import { updateCustomerInfo } from 'services/ghcApi'; import LoadingPlaceholder from 'shared/LoadingPlaceholder'; import SomethingWentWrong from 'shared/SomethingWentWrong'; -import { CustomerShape } from 'types'; +import { useCustomerQuery } from 'hooks/queries'; +import { milmoveLogger } from 'utils/milmoveLog'; -const CreateMoveCustomerInfo = ({ customer, isLoading, isError, ordersId, onUpdate }) => { +const CreateMoveCustomerInfo = () => { + const { customerId } = useParams(); + const { customerData, isLoading, isError } = useCustomerQuery(customerId); const navigate = useNavigate(); const handleBack = () => { navigate('/'); }; const handleClose = () => { - navigate(`../${servicesCounselingRoutes.ORDERS_ADD_PATH}`); + navigate(generatePath(servicesCounselingRoutes.BASE_CUSTOMERS_ORDERS_ADD_PATH, { customerId })); }; const queryClient = useQueryClient(); const { mutate: mutateCustomerInfo } = useMutation(updateCustomerInfo, { @@ -31,12 +33,11 @@ const CreateMoveCustomerInfo = ({ customer, isLoading, isError, ordersId, onUpda }, }); queryClient.invalidateQueries([CUSTOMER, variables.customerId]); - queryClient.invalidateQueries([ORDERS, ordersId]); - onUpdate('success'); handleClose(); }, - onError: () => { - onUpdate('error'); + onError: (error) => { + const errorMsg = error?.response?.body; + milmoveLogger.error(errorMsg); }, }); @@ -79,23 +80,23 @@ const CreateMoveCustomerInfo = ({ customer, isLoading, isError, ordersId, onUpda emailIsPreferred, secondaryTelephone: secondaryPhone || null, }; - mutateCustomerInfo({ customerId: customer.id, ifMatchETag: customer.eTag, body }); + mutateCustomerInfo({ customerId: customerData.id, ifMatchETag: customerData.eTag, body }); }; const initialValues = { - firstName: customer.first_name || '', - lastName: customer.last_name || '', - middleName: customer.middle_name || '', - suffix: customer.suffix || '', - customerTelephone: customer.phone || '', - customerEmail: customer.email || '', - name: customer.backup_contact.name || '', - telephone: customer.backup_contact.phone || '', - secondaryPhone: customer.secondaryTelephone || '', - email: customer.backup_contact.email || '', - customerAddress: customer.current_address || '', - backupAddress: customer.backupAddress || '', - emailIsPreferred: customer.emailIsPreferred || false, - phoneIsPreferred: customer.phoneIsPreferred || false, + firstName: customerData?.first_name || '', + lastName: customerData?.last_name || '', + middleName: customerData?.middle_name || '', + suffix: customerData?.suffix || '', + customerTelephone: customerData?.phone || '', + customerEmail: customerData?.email || '', + name: customerData?.backup_contact.name || '', + telephone: customerData?.backup_contact.phone || '', + secondaryPhone: customerData?.secondaryTelephone || '', + email: customerData?.backup_contact.email || '', + customerAddress: customerData?.current_address || '', + backupAddress: customerData?.backupAddress || '', + emailIsPreferred: customerData?.emailIsPreferred || false, + phoneIsPreferred: customerData?.phoneIsPreferred || false, }; return ( @@ -108,12 +109,4 @@ const CreateMoveCustomerInfo = ({ customer, isLoading, isError, ordersId, onUpda ); }; -CreateMoveCustomerInfo.propTypes = { - customer: CustomerShape.isRequired, - isLoading: PropTypes.bool.isRequired, - isError: PropTypes.bool.isRequired, - ordersId: PropTypes.string.isRequired, - onUpdate: PropTypes.func.isRequired, -}; - export default CreateMoveCustomerInfo; diff --git a/src/pages/Office/CreateMoveCustomerInfo/CreateMoveCustomerInfo.test.jsx b/src/pages/Office/CreateMoveCustomerInfo/CreateMoveCustomerInfo.test.jsx index dbde58c2a16..81f2c46399e 100644 --- a/src/pages/Office/CreateMoveCustomerInfo/CreateMoveCustomerInfo.test.jsx +++ b/src/pages/Office/CreateMoveCustomerInfo/CreateMoveCustomerInfo.test.jsx @@ -1,103 +1,93 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import CreateMoveCustomerInfo from './CreateMoveCustomerInfo'; -import { MockProviders } from 'testUtils'; +import { renderWithProviders } from 'testUtils'; import { updateCustomerInfo } from 'services/ghcApi'; +import { servicesCounselingRoutes } from 'constants/routes'; +import { useCustomerQuery } from 'hooks/queries'; + +const mockNavigate = jest.fn(); +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); +const routingParams = { customerId: '8604447b-cbfc-4d59-a9a1-dec219eb2046' }; +const mockRoutingConfig = { + path: servicesCounselingRoutes.BASE_CUSTOMERS_CUSTOMER_INFO_PATH, + params: routingParams, +}; jest.mock('services/ghcApi', () => ({ ...jest.requireActual('services/ghcApi'), updateCustomerInfo: jest.fn(), })); -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useParams: jest.fn().mockReturnValue({ moveCode: 'LR4T8V' }), +jest.mock('hooks/queries', () => ({ + useCustomerQuery: jest.fn(), })); -const mockCustomer = { - backup_contact: { - email: 'backup@mail.com', - name: 'Jane Backup', - phone: '555-555-1234', +const useCustomerQueryReturnValue = { + customerData: { + backup_contact: { + email: 'backup@mail.com', + name: 'Jane Backup', + phone: '555-555-1234', + }, + backupAddress: { + city: 'Great Falls', + country: 'US', + postalCode: '59402', + state: 'MT', + streetAddress1: '446 South Ave', + }, + current_address: { + city: 'Beverly Hills', + country: 'US', + postalCode: '90210', + state: 'CA', + streetAddress1: '123 Any Street', + }, + email: 'john_doe@mail.com', + first_name: 'John', + last_name: 'Doe', + middle_name: 'Quincey', + suffix: 'Jr.', + phone: '223-444-3434', }, - backupAddress: { - city: 'Great Falls', - country: 'US', - postalCode: '59402', - state: 'MT', - streetAddress1: '446 South Ave', - }, - current_address: { - city: 'Beverly Hills', - country: 'US', - postalCode: '90210', - state: 'CA', - streetAddress1: '123 Any Street', - }, - email: 'john_doe@mail.com', - first_name: 'John', - last_name: 'Doe', - middle_name: 'Quincey', - suffix: 'Jr.', - phone: '223-444-3434', }; const loadingReturnValue = { + ...useCustomerQueryReturnValue, isLoading: true, isError: false, isSuccess: false, }; const errorReturnValue = { + ...useCustomerQueryReturnValue, isLoading: false, isError: true, isSuccess: false, }; -let mockUpdate; - describe('CreateMoveCustomerInfo', () => { - beforeEach(() => { - mockUpdate = jest.fn(); - }); - describe('check loading and error component states', () => { it('renders the Loading Placeholder when the query is still loading', async () => { - updateCustomerInfo.mockReturnValue(loadingReturnValue); - - render( - - - , - ); + useCustomerQuery.mockReturnValue(loadingReturnValue); + + renderWithProviders(, mockRoutingConfig); const h2 = await screen.getByRole('heading', { name: 'Loading, please wait...', level: 2 }); expect(h2).toBeInTheDocument(); }); it('renders the Something Went Wrong component when the query errors', async () => { - updateCustomerInfo.mockReturnValue(errorReturnValue); - - render( - - - , - ); + useCustomerQuery.mockReturnValue(errorReturnValue); + + renderWithProviders(, mockRoutingConfig); const errorMessage = await screen.getByText(/Something went wrong./); expect(errorMessage).toBeInTheDocument(); @@ -105,82 +95,43 @@ describe('CreateMoveCustomerInfo', () => { }); it('populates initial field values', async () => { - render( - - - , - ); + useCustomerQuery.mockReturnValue(useCustomerQueryReturnValue); + + renderWithProviders(, mockRoutingConfig); + const { customerData } = useCustomerQueryReturnValue; await waitFor(() => { - expect(screen.getByLabelText('First name').value).toEqual(mockCustomer.first_name); - expect(screen.getByLabelText(/Middle name/i).value).toEqual(mockCustomer.middle_name); - expect(screen.getByLabelText('Last name').value).toEqual(mockCustomer.last_name); - expect(screen.getByLabelText(/Suffix/i).value).toEqual(mockCustomer.suffix); + expect(screen.getByLabelText('First name').value).toEqual(customerData.first_name); + expect(screen.getByLabelText(/Middle name/i).value).toEqual(customerData.middle_name); + expect(screen.getByLabelText('Last name').value).toEqual(customerData.last_name); + expect(screen.getByLabelText(/Suffix/i).value).toEqual(customerData.suffix); // to get around the two inputs labeled "Phone" on the screen - expect(screen.getByDisplayValue(mockCustomer.phone).value).toEqual(mockCustomer.phone); - expect(screen.getByDisplayValue(mockCustomer.backup_contact.phone).value).toEqual( - mockCustomer.backup_contact.phone, + expect(screen.getByDisplayValue(customerData.phone).value).toEqual(customerData.phone); + expect(screen.getByDisplayValue(customerData.backup_contact.phone).value).toEqual( + customerData.backup_contact.phone, ); // to get around the two inputs labeled "Email" on the screen - expect(screen.getByDisplayValue(mockCustomer.email).value).toEqual(mockCustomer.email); - expect(screen.getByDisplayValue(mockCustomer.backup_contact.email).value).toEqual( - mockCustomer.backup_contact.email, + expect(screen.getByDisplayValue(customerData.email).value).toEqual(customerData.email); + expect(screen.getByDisplayValue(customerData.backup_contact.email).value).toEqual( + customerData.backup_contact.email, ); - expect(screen.getByDisplayValue('123 Any Street').value).toEqual(mockCustomer.current_address.streetAddress1); - expect(screen.getByDisplayValue('Beverly Hills').value).toEqual(mockCustomer.current_address.city); - expect(screen.getByDisplayValue('CA').value).toEqual(mockCustomer.current_address.state); - expect(screen.getByDisplayValue('90210').value).toEqual(mockCustomer.current_address.postalCode); - expect(screen.getByDisplayValue('Jane Backup').value).toEqual(mockCustomer.backup_contact.name); + expect(screen.getByDisplayValue('123 Any Street').value).toEqual(customerData.current_address.streetAddress1); + expect(screen.getByDisplayValue('Beverly Hills').value).toEqual(customerData.current_address.city); + expect(screen.getByDisplayValue('CA').value).toEqual(customerData.current_address.state); + expect(screen.getByDisplayValue('90210').value).toEqual(customerData.current_address.postalCode); + expect(screen.getByDisplayValue('Jane Backup').value).toEqual(customerData.backup_contact.name); }); }); - it('calls onUpdate prop with success on successful form submission', async () => { + it('calls updateCustomerInfo on submission', async () => { + useCustomerQuery.mockReturnValue(useCustomerQueryReturnValue); updateCustomerInfo.mockImplementation(() => Promise.resolve({ customer: { customerId: '123' } })); - render( - - - , - ); - const saveBtn = screen.getByRole('button', { name: 'Save' }); - await userEvent.click(saveBtn); - - await waitFor(() => { - expect(mockUpdate).toHaveBeenCalledWith('success'); - }); - }); - - it('calls onUpdate prop with error on unsuccessful form submission', async () => { - jest.spyOn(console, 'error').mockImplementation(() => {}); - updateCustomerInfo.mockImplementation(() => Promise.reject()); - - render( - - - , - ); + renderWithProviders(, mockRoutingConfig); const saveBtn = screen.getByRole('button', { name: 'Save' }); await userEvent.click(saveBtn); - await waitFor(async () => { - await expect(mockUpdate).toHaveBeenCalledWith('error'); + await waitFor(() => { + expect(updateCustomerInfo).toHaveBeenCalled(); }); }); }); diff --git a/src/pages/Office/CustomerOnboarding/CreateCustomerForm.jsx b/src/pages/Office/CustomerOnboarding/CreateCustomerForm.jsx index 880e6fb1e6f..7c495d16f47 100644 --- a/src/pages/Office/CustomerOnboarding/CreateCustomerForm.jsx +++ b/src/pages/Office/CustomerOnboarding/CreateCustomerForm.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { GridContainer, Grid, Alert, Label, Radio, Fieldset } from '@trussworks/react-uswds'; -import { useNavigate } from 'react-router-dom'; +import { generatePath, useNavigate } from 'react-router-dom'; import { Field, Formik } from 'formik'; import * as Yup from 'yup'; import { connect } from 'react-redux'; @@ -12,7 +12,7 @@ import styles from './CreateCustomerForm.module.scss'; import { Form } from 'components/form/Form'; import TextField from 'components/form/fields/TextField/TextField'; import NotificationScrollToTop from 'components/NotificationScrollToTop'; -import { generalRoutes } from 'constants/routes'; +import { servicesCounselingRoutes } from 'constants/routes'; import WizardNavigation from 'components/Customer/WizardNavigation/WizardNavigation'; import SectionWrapper from 'components/Customer/SectionWrapper'; import formStyles from 'styles/form.module.scss'; @@ -73,7 +73,7 @@ export const CreateCustomerForm = ({ setFlashMessage }) => { }; const handleBack = () => { - navigate(generalRoutes.BASE_QUEUE_SEARCH_PATH); + navigate(servicesCounselingRoutes.BASE_CUSTOMER_SEARCH_PATH); }; const onSubmit = async (values) => { @@ -105,9 +105,10 @@ export const CreateCustomerForm = ({ setFlashMessage }) => { }; return createCustomerWithOktaOption({ body }) - .then(() => { + .then((res) => { + const customerId = Object.keys(res.createdCustomer)[0]; setFlashMessage('CUSTOMER_CREATE_SUCCESS', 'success', `Customer created successfully.`); - navigate(generalRoutes.BASE_QUEUE_SEARCH_PATH); + navigate(generatePath(servicesCounselingRoutes.BASE_CUSTOMERS_ORDERS_ADD_PATH, { customerId })); }) .catch((e) => { const { response } = e; diff --git a/src/pages/Office/CustomerOnboarding/CreateCustomerForm.test.jsx b/src/pages/Office/CustomerOnboarding/CreateCustomerForm.test.jsx index f6ae6389b03..d60ee2b4ec6 100644 --- a/src/pages/Office/CustomerOnboarding/CreateCustomerForm.test.jsx +++ b/src/pages/Office/CustomerOnboarding/CreateCustomerForm.test.jsx @@ -1,11 +1,13 @@ import React from 'react'; import { render, fireEvent, waitFor, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { generatePath } from 'react-router'; import { CreateCustomerForm } from './CreateCustomerForm'; import { MockProviders } from 'testUtils'; import { createCustomerWithOktaOption } from 'services/ghcApi'; +import { servicesCounselingRoutes } from 'constants/routes'; const mockNavigate = jest.fn(); jest.mock('react-router-dom', () => ({ @@ -63,47 +65,51 @@ const fakePayload = { }; const fakeResponse = { - affiliation: 'string', - firstName: 'John', - lastName: 'Doe', - telephone: '216-421-1392', - personalEmail: '73sGJ6jq7cS%6@PqElR.WUzkqFNvtduyyA', - suffix: 'Jr.', - middleName: 'David', - residentialAddress: { - id: 'c56a4180-65aa-42ec-a945-5fd21dec0538', - streetAddress1: '123 Main Ave', - streetAddress2: 'Apartment 9000', - streetAddress3: 'Montmârtre', - city: 'Anytown', - eTag: 'string', - state: 'AL', - postalCode: '90210', - country: 'USA', - }, - backupContact: { - name: 'string', - email: 'backupContact@mail.com', - phone: '381-100-5880', - }, - id: 'c56a4180-65aa-42ec-a945-5fd21dec0538', - edipi: 'string', - userID: 'c56a4180-65aa-42ec-a945-5fd21dec0538', - oktaID: 'string', - oktaEmail: 'string', - phoneIsPreferred: true, - emailIsPreferred: true, - secondaryTelephone: '499-793-2722', - backupAddress: { - id: 'c56a4180-65aa-42ec-a945-5fd21dec0538', - streetAddress1: '123 Main Ave', - streetAddress2: 'Apartment 9000', - streetAddress3: 'Montmârtre', - city: 'Anytown', - eTag: 'string', - state: 'AL', - postalCode: '90210', - country: 'USA', + createdCustomer: { + '7575b55a-0e14-4f11-8e42-10232d22b135': { + affiliation: 'string', + firstName: 'John', + lastName: 'Doe', + telephone: '216-421-1392', + personalEmail: '73sGJ6jq7cS%6@PqElR.WUzkqFNvtduyyA', + suffix: 'Jr.', + middleName: 'David', + residentialAddress: { + id: 'c56a4180-65aa-42ec-a945-5fd21dec0538', + streetAddress1: '123 Main Ave', + streetAddress2: 'Apartment 9000', + streetAddress3: 'Montmârtre', + city: 'Anytown', + eTag: 'string', + state: 'AL', + postalCode: '90210', + country: 'USA', + }, + backupContact: { + name: 'string', + email: 'backupContact@mail.com', + phone: '381-100-5880', + }, + id: 'c56a4180-65aa-42ec-a945-5fd21dec0538', + edipi: 'string', + userID: 'c56a4180-65aa-42ec-a945-5fd21dec0538', + oktaID: 'string', + oktaEmail: 'string', + phoneIsPreferred: true, + emailIsPreferred: true, + secondaryTelephone: '499-793-2722', + backupAddress: { + id: 'c56a4180-65aa-42ec-a945-5fd21dec0538', + streetAddress1: '123 Main Ave', + streetAddress2: 'Apartment 9000', + streetAddress3: 'Montmârtre', + city: 'Anytown', + eTag: 'string', + state: 'AL', + postalCode: '90210', + country: 'USA', + }, + }, }, }; @@ -111,6 +117,10 @@ const testProps = { setFlashMessage: jest.fn(), }; +const ordersPath = generatePath(servicesCounselingRoutes.BASE_CUSTOMERS_ORDERS_ADD_PATH, { + customerId: '7575b55a-0e14-4f11-8e42-10232d22b135', +}); + describe('CreateCustomerForm', () => { it('renders without crashing', async () => { render( @@ -194,8 +204,10 @@ describe('CreateCustomerForm', () => { }); await userEvent.click(saveBtn); - expect(createCustomerWithOktaOption).toHaveBeenCalled(); - expect(mockNavigate).toHaveBeenCalled(); + await waitFor(() => { + expect(createCustomerWithOktaOption).toHaveBeenCalled(); + expect(mockNavigate).toHaveBeenCalledWith(ordersPath); + }); }, 10000); it('submits the form and tests for unsupported state validation', async () => { diff --git a/src/pages/Office/ServicesCounselingAddOrders/ServicesCounselingAddOrders.jsx b/src/pages/Office/ServicesCounselingAddOrders/ServicesCounselingAddOrders.jsx index 6ac5e1464b1..af6eab72f19 100644 --- a/src/pages/Office/ServicesCounselingAddOrders/ServicesCounselingAddOrders.jsx +++ b/src/pages/Office/ServicesCounselingAddOrders/ServicesCounselingAddOrders.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { useQueryClient, useMutation } from '@tanstack/react-query'; import { GridContainer, Grid } from '@trussworks/react-uswds'; -import { generatePath, useNavigate } from 'react-router-dom'; +import { generatePath, useNavigate, useParams } from 'react-router-dom'; import styles from './ServicesCounselingAddOrders.module.scss'; @@ -14,7 +14,8 @@ import { formatDateForSwagger } from 'shared/dates'; import { servicesCounselingRoutes } from 'constants/routes'; import { milmoveLogger } from 'utils/milmoveLog'; -const ServicesCounselingAddOrders = ({ customer }) => { +const ServicesCounselingAddOrders = () => { + const { customerId } = useParams(); const navigate = useNavigate(); const handleBack = () => { navigate(-1); @@ -59,7 +60,7 @@ const ServicesCounselingAddOrders = ({ customer }) => { const handleSubmit = (values) => { const body = { ...values, - serviceMemberId: customer.id, + serviceMemberId: customerId, newDutyLocationId: values.newDutyLocation.id, hasDependents: formatYesNoAPIValue(values.hasDependents), reportByDate: formatDateForSwagger(values.reportByDate), diff --git a/src/pages/Office/ServicesCounselingMoveInfo/ServicesCounselingMoveInfo.jsx b/src/pages/Office/ServicesCounselingMoveInfo/ServicesCounselingMoveInfo.jsx index 3c6de4b9181..17865c30e77 100644 --- a/src/pages/Office/ServicesCounselingMoveInfo/ServicesCounselingMoveInfo.jsx +++ b/src/pages/Office/ServicesCounselingMoveInfo/ServicesCounselingMoveInfo.jsx @@ -22,9 +22,6 @@ const ServicesCounselingMoveDetails = lazy(() => const ServicesCounselingAddShipment = lazy(() => import('pages/Office/ServicesCounselingAddShipment/ServicesCounselingAddShipment'), ); -const ServicesCounselingAddOrders = lazy(() => - import('pages/Office/ServicesCounselingAddOrders/ServicesCounselingAddOrders'), -); const ServicesCounselingEditShipmentDetails = lazy(() => import('pages/Office/ServicesCounselingEditShipmentDetails/ServicesCounselingEditShipmentDetails'), ); @@ -36,7 +33,6 @@ const ReviewDocuments = lazy(() => import('pages/Office/PPM/ReviewDocuments/Revi const ServicesCounselingReviewShipmentWeights = lazy(() => import('pages/Office/ServicesCounselingReviewShipmentWeights/ServicesCounselingReviewShipmentWeights'), ); -const CreateMoveCustomerInfo = lazy(() => import('pages/Office/CreateMoveCustomerInfo/CreateMoveCustomerInfo')); const ServicesCounselingMoveInfo = () => { const [unapprovedShipmentCount, setUnapprovedShipmentCount] = React.useState(0); @@ -123,20 +119,6 @@ const ServicesCounselingMoveInfo = () => { end: true, }, pathname, - ) || - matchPath( - { - path: servicesCounselingRoutes.BASE_ORDERS_ADD_PATH, - end: true, - }, - pathname, - ) || - matchPath( - { - path: servicesCounselingRoutes.BASE_CREATE_MOVE_EDIT_CUSTOMER_PATH, - end: true, - }, - pathname, ); if (isLoading) return ; @@ -261,31 +243,6 @@ const ServicesCounselingMoveInfo = () => { exact element={} /> - - } - /> - - } - /> {/* TODO - clarify role/tab access */} } /> diff --git a/src/pages/Office/ServicesCounselingQueue/ServicesCounselingQueue.jsx b/src/pages/Office/ServicesCounselingQueue/ServicesCounselingQueue.jsx index de2bceca003..a74b0dcf3ab 100644 --- a/src/pages/Office/ServicesCounselingQueue/ServicesCounselingQueue.jsx +++ b/src/pages/Office/ServicesCounselingQueue/ServicesCounselingQueue.jsx @@ -22,6 +22,7 @@ import { useServicesCounselingQueuePPMQueries, useUserQueries, useMoveSearchQueries, + useCustomerSearchQueries, } from 'hooks/queries'; import { DATE_FORMAT_STRING } from 'shared/constants'; import { formatDateFromIso, serviceMemberAgencyLabel } from 'utils/formatters'; @@ -38,6 +39,7 @@ import { milmoveLogger } from 'utils/milmoveLog'; import { CHECK_SPECIAL_ORDERS_TYPES, SPECIAL_ORDERS_TYPES } from 'constants/orders'; import ConnectedFlashMessage from 'containers/FlashMessage/FlashMessage'; import { isNullUndefinedOrWhitespace } from 'shared/utils'; +import CustomerSearchForm from 'components/CustomerSearchForm/CustomerSearchForm'; const counselingColumns = () => [ createHeader('ID', 'id'), @@ -238,14 +240,14 @@ const ServicesCounselingQueue = () => { fetchData(); }, [setErrorState]); - const handleClick = (values, e) => { - if (e?.target?.innerHTML === 'Create New Move') { - navigate( - generatePath(servicesCounselingRoutes.BASE_CREATE_MOVE_EDIT_CUSTOMER_PATH, { moveCode: values.locator }), - ); - } else { - navigate(generatePath(servicesCounselingRoutes.BASE_MOVE_VIEW_PATH, { moveCode: values.locator })); - } + const handleClick = (values) => { + navigate(generatePath(servicesCounselingRoutes.BASE_MOVE_VIEW_PATH, { moveCode: values.locator })); + }; + + const handleCustomerSearchClick = (values) => { + navigate( + generatePath(servicesCounselingRoutes.BASE_CUSTOMERS_CUSTOMER_INFO_PATH, { customerId: values.customerID }), + ); }; const handleAddCustomerClick = () => { @@ -276,6 +278,53 @@ const ServicesCounselingQueue = () => { setSearchHappened(true); }, []); + const tabs = [ + (isActive ? 'usa-current' : '')} + to={servicesCounselingRoutes.BASE_QUEUE_COUNSELING_PATH} + > + + Counseling Queue + + , + (isActive ? 'usa-current' : '')} + to={servicesCounselingRoutes.BASE_QUEUE_CLOSEOUT_PATH} + > + + PPM Closeout Queue + + , + (isActive ? 'usa-current' : '')} + to={generalRoutes.BASE_QUEUE_SEARCH_PATH} + onClick={() => setSearchHappened(false)} + > + + Move Search + + , + ]; + + // when FEATURE_FLAG_COUNSELOR_MOVE_CREATE is removed, + // this can simply be the tabs for this component + const ffTabs = [ + ...tabs, + (isActive ? 'usa-current' : '')} + to={servicesCounselingRoutes.BASE_CUSTOMER_SEARCH_PATH} + onClick={() => setSearchHappened(false)} + > + + Customer Search + + , + ]; + // If the office user is in a closeout GBLOC and on the closeout tab, then we will want to disable // the column filter for the closeout location column because it will have no effect. const officeUserGBLOC = data?.office_user?.transportation_office?.gbloc; @@ -289,42 +338,10 @@ const ServicesCounselingQueue = () => { ); } + const navTabs = () => (isCounselorMoveCreateFFEnabled ? ffTabs : tabs); const renderNavBar = () => { - return ( - (isActive ? 'usa-current' : '')} - to={servicesCounselingRoutes.BASE_QUEUE_COUNSELING_PATH} - > - - Counseling Queue - - , - (isActive ? 'usa-current' : '')} - to={servicesCounselingRoutes.BASE_QUEUE_CLOSEOUT_PATH} - > - - PPM Closeout Queue - - , - (isActive ? 'usa-current' : '')} - to={generalRoutes.BASE_QUEUE_SEARCH_PATH} - > - - Search - - , - ]} - /> - ); + return ; }; if (queueType === 'Search') { @@ -334,11 +351,6 @@ const ServicesCounselingQueue = () => {

Search for a move

- {searchHappened && counselorMoveCreateFeatureFlag && ( - - )}
{searchHappened && ( @@ -355,7 +367,7 @@ const ServicesCounselingQueue = () => { dodID={search.dodID} customerName={search.customerName} roleType={roleTypes.SERVICES_COUNSELOR} - isCounselorMoveCreateFFEnabled={isCounselorMoveCreateFFEnabled} + searchType="move" /> )} @@ -403,6 +415,40 @@ const ServicesCounselingQueue = () => { ); } + if (queueType === 'customer-search') { + return ( +
+ {renderNavBar()} + +
+

Search for a customer

+ {searchHappened && counselorMoveCreateFeatureFlag && ( + + )} +
+ + {searchHappened && ( + + )} +
+ ); + } return ; }; diff --git a/src/pages/Office/index.jsx b/src/pages/Office/index.jsx index 49b6b3137a7..77c3cff051f 100644 --- a/src/pages/Office/index.jsx +++ b/src/pages/Office/index.jsx @@ -90,6 +90,10 @@ const PrimeUIShipmentUpdateDestinationAddress = lazy(() => const QAECSRMoveSearch = lazy(() => import('pages/Office/QAECSRMoveSearch/QAECSRMoveSearch')); const CreateCustomerForm = lazy(() => import('pages/Office/CustomerOnboarding/CreateCustomerForm')); +const CreateMoveCustomerInfo = lazy(() => import('pages/Office/CreateMoveCustomerInfo/CreateMoveCustomerInfo')); +const ServicesCounselingAddOrders = lazy(() => + import('pages/Office/ServicesCounselingAddOrders/ServicesCounselingAddOrders'), +); export class OfficeApp extends Component { constructor(props) { super(props); @@ -272,6 +276,22 @@ export class OfficeApp extends Component { } /> )} + + + + } + /> + + + + } + /> {activeRole === roleTypes.TIO && ( { + paramFilters[`${filter.id}`] = filter.value; + }); + return makeGHCRequest( + 'customer.searchCustomers', + { + body: { + sort, + order, + page: currentPage, + perPage: currentPageSize, + ...paramFilters, + }, + }, + { schemaKey: 'searchMovesResult', normalize: false }, + ); +} diff --git a/swagger-def/ghc.yaml b/swagger-def/ghc.yaml index 48d0faa5b71..0354a92b451 100644 --- a/swagger-def/ghc.yaml +++ b/swagger-def/ghc.yaml @@ -173,6 +173,68 @@ paths: $ref: '#/responses/ServerError' x-permissions: - update.customer + /customer/search: + post: + produces: + - application/json + consumes: + - application/json + summary: Search customers by DOD ID or customer name + description: > + Search customers by DOD ID or customer name. Used by services counselors to locate profiles to update, find attached moves, and to create new moves. + operationId: searchCustomers + tags: + - customer + parameters: + - in: body + name: body + schema: + properties: + page: + type: integer + description: requested page of results + perPage: + type: integer + dodID: + description: DOD ID + type: string + minLength: 10 + maxLength: 10 + x-nullable: true + branch: + description: Branch + type: string + minLength: 1 + customerName: + description: Customer Name + type: string + minLength: 1 + x-nullable: true + sort: + type: string + x-nullable: true + enum: + [ + customerName, + dodID, + branch, + personalEmail, + telephone, + ] + order: + type: string + x-nullable: true + enum: [asc, desc] + description: field that results should be sorted by + responses: + '200': + description: Successfully returned all customers matching the criteria + schema: + $ref: '#/definitions/SearchCustomersResult' + '403': + $ref: '#/responses/PermissionDenied' + '500': + $ref: '#/responses/ServerError' '/move/{locator}': parameters: - description: Code used to identify a move in the system @@ -3910,6 +3972,50 @@ definitions: type: boolean cacUser: type: boolean + SearchCustomersResult: + type: object + properties: + page: + type: integer + perPage: + type: integer + totalCount: + type: integer + searchCustomers: + $ref: '#/definitions/SearchCustomers' + SearchCustomers: + type: array + items: + $ref: '#/definitions/SearchCustomer' + SearchCustomer: + type: object + properties: + id: + type: string + format: uuid + firstName: + type: string + example: John + x-nullable: true + lastName: + type: string + example: Doe + x-nullable: true + dodID: + type: string + x-nullable: true + branch: + type: string + telephone: + type: string + format: telephone + pattern: '^[2-9]\d{2}-\d{3}-\d{4}$' + x-nullable: true + personalEmail: + type: string + format: x-email + example: personalEmail@email.com + pattern: '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' Entitlements: properties: id: diff --git a/swagger/ghc.yaml b/swagger/ghc.yaml index daa829af0dd..df951e13080 100644 --- a/swagger/ghc.yaml +++ b/swagger/ghc.yaml @@ -191,6 +191,70 @@ paths: $ref: '#/responses/ServerError' x-permissions: - update.customer + /customer/search: + post: + produces: + - application/json + consumes: + - application/json + summary: Search customers by DOD ID or customer name + description: > + Search customers by DOD ID or customer name. Used by services counselors + to locate profiles to update, find attached moves, and to create new + moves. + operationId: searchCustomers + tags: + - customer + parameters: + - in: body + name: body + schema: + properties: + page: + type: integer + description: requested page of results + perPage: + type: integer + dodID: + description: DOD ID + type: string + minLength: 10 + maxLength: 10 + x-nullable: true + branch: + description: Branch + type: string + minLength: 1 + customerName: + description: Customer Name + type: string + minLength: 1 + x-nullable: true + sort: + type: string + x-nullable: true + enum: + - customerName + - dodID + - branch + - personalEmail + - telephone + order: + type: string + x-nullable: true + enum: + - asc + - desc + description: field that results should be sorted by + responses: + '200': + description: Successfully returned all customers matching the criteria + schema: + $ref: '#/definitions/SearchCustomersResult' + '403': + $ref: '#/responses/PermissionDenied' + '500': + $ref: '#/responses/ServerError' /move/{locator}: parameters: - description: Code used to identify a move in the system @@ -4055,6 +4119,50 @@ definitions: type: boolean cacUser: type: boolean + SearchCustomersResult: + type: object + properties: + page: + type: integer + perPage: + type: integer + totalCount: + type: integer + searchCustomers: + $ref: '#/definitions/SearchCustomers' + SearchCustomers: + type: array + items: + $ref: '#/definitions/SearchCustomer' + SearchCustomer: + type: object + properties: + id: + type: string + format: uuid + firstName: + type: string + example: John + x-nullable: true + lastName: + type: string + example: Doe + x-nullable: true + dodID: + type: string + x-nullable: true + branch: + type: string + telephone: + type: string + format: telephone + pattern: ^[2-9]\d{2}-\d{3}-\d{4}$ + x-nullable: true + personalEmail: + type: string + format: x-email + example: personalEmail@email.com + pattern: ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ Entitlements: properties: id: