diff --git a/pkg/gen/ghcapi/configure_mymove.go b/pkg/gen/ghcapi/configure_mymove.go index 58c12bae385..dc17dbeda82 100644 --- a/pkg/gen/ghcapi/configure_mymove.go +++ b/pkg/gen/ghcapi/configure_mymove.go @@ -95,6 +95,11 @@ func configureAPI(api *ghcoperations.MymoveAPI) http.Handler { return middleware.NotImplemented("operation report_violations.AssociateReportViolations has not yet been implemented") }) } + if api.PaymentRequestsBulkDownloadHandler == nil { + api.PaymentRequestsBulkDownloadHandler = payment_requests.BulkDownloadHandlerFunc(func(params payment_requests.BulkDownloadParams) middleware.Responder { + return middleware.NotImplemented("operation payment_requests.BulkDownload has not yet been implemented") + }) + } if api.OrderCounselingUpdateAllowanceHandler == nil { api.OrderCounselingUpdateAllowanceHandler = order.CounselingUpdateAllowanceHandlerFunc(func(params order.CounselingUpdateAllowanceParams) middleware.Responder { return middleware.NotImplemented("operation order.CounselingUpdateAllowance has not yet been implemented") diff --git a/pkg/gen/ghcapi/embedded_spec.go b/pkg/gen/ghcapi/embedded_spec.go index 45dcae47d7e..4e4077fd3ae 100644 --- a/pkg/gen/ghcapi/embedded_spec.go +++ b/pkg/gen/ghcapi/embedded_spec.go @@ -3226,6 +3226,49 @@ func init() { } ] }, + "/payment-requests/{paymentRequestID}/bulkDownload": { + "get": { + "description": "This endpoint downloads all uploaded payment request documentation combined into a single PDF.\n", + "produces": [ + "application/pdf" + ], + "tags": [ + "paymentRequests" + ], + "summary": "Downloads all Payment Request documents as a PDF", + "operationId": "bulkDownload", + "responses": { + "200": { + "description": "Payment Request Files PDF", + "schema": { + "type": "file", + "format": "binary" + }, + "headers": { + "Content-Disposition": { + "type": "string", + "description": "File name to download" + } + } + }, + "400": { + "$ref": "#/responses/InvalidRequest" + }, + "500": { + "$ref": "#/responses/ServerError" + } + } + }, + "parameters": [ + { + "type": "string", + "description": "the id for the payment-request with files to be downloaded", + "name": "paymentRequestID", + "in": "path", + "required": true + } + ] + }, "/payment-requests/{paymentRequestID}/shipments-payment-sit-balance": { "get": { "description": "Returns all shipment payment request SIT usage to support partial SIT invoicing", @@ -18013,6 +18056,55 @@ func init() { } ] }, + "/payment-requests/{paymentRequestID}/bulkDownload": { + "get": { + "description": "This endpoint downloads all uploaded payment request documentation combined into a single PDF.\n", + "produces": [ + "application/pdf" + ], + "tags": [ + "paymentRequests" + ], + "summary": "Downloads all Payment Request documents as a PDF", + "operationId": "bulkDownload", + "responses": { + "200": { + "description": "Payment Request Files PDF", + "schema": { + "type": "file", + "format": "binary" + }, + "headers": { + "Content-Disposition": { + "type": "string", + "description": "File name to download" + } + } + }, + "400": { + "description": "The request payload is invalid", + "schema": { + "$ref": "#/definitions/Error" + } + }, + "500": { + "description": "A server error occurred", + "schema": { + "$ref": "#/definitions/Error" + } + } + } + }, + "parameters": [ + { + "type": "string", + "description": "the id for the payment-request with files to be downloaded", + "name": "paymentRequestID", + "in": "path", + "required": true + } + ] + }, "/payment-requests/{paymentRequestID}/shipments-payment-sit-balance": { "get": { "description": "Returns all shipment payment request SIT usage to support partial SIT invoicing", diff --git a/pkg/gen/ghcapi/ghcoperations/mymove_api.go b/pkg/gen/ghcapi/ghcoperations/mymove_api.go index fe82a22c37f..e9eda51b651 100644 --- a/pkg/gen/ghcapi/ghcoperations/mymove_api.go +++ b/pkg/gen/ghcapi/ghcoperations/mymove_api.go @@ -84,6 +84,9 @@ func NewMymoveAPI(spec *loads.Document) *MymoveAPI { ReportViolationsAssociateReportViolationsHandler: report_violations.AssociateReportViolationsHandlerFunc(func(params report_violations.AssociateReportViolationsParams) middleware.Responder { return middleware.NotImplemented("operation report_violations.AssociateReportViolations has not yet been implemented") }), + PaymentRequestsBulkDownloadHandler: payment_requests.BulkDownloadHandlerFunc(func(params payment_requests.BulkDownloadParams) middleware.Responder { + return middleware.NotImplemented("operation payment_requests.BulkDownload has not yet been implemented") + }), OrderCounselingUpdateAllowanceHandler: order.CounselingUpdateAllowanceHandlerFunc(func(params order.CounselingUpdateAllowanceParams) middleware.Responder { return middleware.NotImplemented("operation order.CounselingUpdateAllowance has not yet been implemented") }), @@ -419,6 +422,8 @@ type MymoveAPI struct { ShipmentApproveShipmentDiversionHandler shipment.ApproveShipmentDiversionHandler // ReportViolationsAssociateReportViolationsHandler sets the operation handler for the associate report violations operation ReportViolationsAssociateReportViolationsHandler report_violations.AssociateReportViolationsHandler + // PaymentRequestsBulkDownloadHandler sets the operation handler for the bulk download operation + PaymentRequestsBulkDownloadHandler payment_requests.BulkDownloadHandler // OrderCounselingUpdateAllowanceHandler sets the operation handler for the counseling update allowance operation OrderCounselingUpdateAllowanceHandler order.CounselingUpdateAllowanceHandler // OrderCounselingUpdateOrderHandler sets the operation handler for the counseling update order operation @@ -703,6 +708,9 @@ func (o *MymoveAPI) Validate() error { if o.ReportViolationsAssociateReportViolationsHandler == nil { unregistered = append(unregistered, "report_violations.AssociateReportViolationsHandler") } + if o.PaymentRequestsBulkDownloadHandler == nil { + unregistered = append(unregistered, "payment_requests.BulkDownloadHandler") + } if o.OrderCounselingUpdateAllowanceHandler == nil { unregistered = append(unregistered, "order.CounselingUpdateAllowanceHandler") } @@ -1094,6 +1102,10 @@ func (o *MymoveAPI) initHandlerCache() { o.handlers["POST"] = make(map[string]http.Handler) } o.handlers["POST"]["/report-violations/{reportID}"] = report_violations.NewAssociateReportViolations(o.context, o.ReportViolationsAssociateReportViolationsHandler) + if o.handlers["GET"] == nil { + o.handlers["GET"] = make(map[string]http.Handler) + } + o.handlers["GET"]["/payment-requests/{paymentRequestID}/bulkDownload"] = payment_requests.NewBulkDownload(o.context, o.PaymentRequestsBulkDownloadHandler) if o.handlers["PATCH"] == nil { o.handlers["PATCH"] = make(map[string]http.Handler) } diff --git a/pkg/gen/ghcapi/ghcoperations/payment_requests/bulk_download.go b/pkg/gen/ghcapi/ghcoperations/payment_requests/bulk_download.go new file mode 100644 index 00000000000..78f7901ab05 --- /dev/null +++ b/pkg/gen/ghcapi/ghcoperations/payment_requests/bulk_download.go @@ -0,0 +1,58 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package payment_requests + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the generate command + +import ( + "net/http" + + "github.com/go-openapi/runtime/middleware" +) + +// BulkDownloadHandlerFunc turns a function with the right signature into a bulk download handler +type BulkDownloadHandlerFunc func(BulkDownloadParams) middleware.Responder + +// Handle executing the request and returning a response +func (fn BulkDownloadHandlerFunc) Handle(params BulkDownloadParams) middleware.Responder { + return fn(params) +} + +// BulkDownloadHandler interface for that can handle valid bulk download params +type BulkDownloadHandler interface { + Handle(BulkDownloadParams) middleware.Responder +} + +// NewBulkDownload creates a new http.Handler for the bulk download operation +func NewBulkDownload(ctx *middleware.Context, handler BulkDownloadHandler) *BulkDownload { + return &BulkDownload{Context: ctx, Handler: handler} +} + +/* + BulkDownload swagger:route GET /payment-requests/{paymentRequestID}/bulkDownload paymentRequests bulkDownload + +# Downloads all Payment Request documents as a PDF + +This endpoint downloads all uploaded payment request documentation combined into a single PDF. +*/ +type BulkDownload struct { + Context *middleware.Context + Handler BulkDownloadHandler +} + +func (o *BulkDownload) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + route, rCtx, _ := o.Context.RouteInfo(r) + if rCtx != nil { + *r = *rCtx + } + var Params = NewBulkDownloadParams() + 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) + +} diff --git a/pkg/gen/ghcapi/ghcoperations/payment_requests/bulk_download_parameters.go b/pkg/gen/ghcapi/ghcoperations/payment_requests/bulk_download_parameters.go new file mode 100644 index 00000000000..ff67f05c88a --- /dev/null +++ b/pkg/gen/ghcapi/ghcoperations/payment_requests/bulk_download_parameters.go @@ -0,0 +1,71 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package payment_requests + +// 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/middleware" + "github.com/go-openapi/strfmt" +) + +// NewBulkDownloadParams creates a new BulkDownloadParams object +// +// There are no default values defined in the spec. +func NewBulkDownloadParams() BulkDownloadParams { + + return BulkDownloadParams{} +} + +// BulkDownloadParams contains all the bound params for the bulk download operation +// typically these are obtained from a http.Request +// +// swagger:parameters bulkDownload +type BulkDownloadParams struct { + + // HTTP Request Object + HTTPRequest *http.Request `json:"-"` + + /*the id for the payment-request with files to be downloaded + Required: true + In: path + */ + PaymentRequestID string +} + +// 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 NewBulkDownloadParams() beforehand. +func (o *BulkDownloadParams) BindRequest(r *http.Request, route *middleware.MatchedRoute) error { + var res []error + + o.HTTPRequest = r + + rPaymentRequestID, rhkPaymentRequestID, _ := route.Params.GetOK("paymentRequestID") + if err := o.bindPaymentRequestID(rPaymentRequestID, rhkPaymentRequestID, route.Formats); err != nil { + res = append(res, err) + } + if len(res) > 0 { + return errors.CompositeValidationError(res...) + } + return nil +} + +// bindPaymentRequestID binds and validates parameter PaymentRequestID from path. +func (o *BulkDownloadParams) bindPaymentRequestID(rawData []string, hasKey bool, formats strfmt.Registry) error { + var raw string + if len(rawData) > 0 { + raw = rawData[len(rawData)-1] + } + + // Required: true + // Parameter is provided by construction from the route + o.PaymentRequestID = raw + + return nil +} diff --git a/pkg/gen/ghcapi/ghcoperations/payment_requests/bulk_download_responses.go b/pkg/gen/ghcapi/ghcoperations/payment_requests/bulk_download_responses.go new file mode 100644 index 00000000000..b00eca8e5c1 --- /dev/null +++ b/pkg/gen/ghcapi/ghcoperations/payment_requests/bulk_download_responses.go @@ -0,0 +1,170 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package payment_requests + +// This file was generated by the swagger tool. +// Editing this file might prove futile when you re-run the swagger generate command + +import ( + "io" + "net/http" + + "github.com/go-openapi/runtime" + + "github.com/transcom/mymove/pkg/gen/ghcmessages" +) + +// BulkDownloadOKCode is the HTTP code returned for type BulkDownloadOK +const BulkDownloadOKCode int = 200 + +/* +BulkDownloadOK Payment Request Files PDF + +swagger:response bulkDownloadOK +*/ +type BulkDownloadOK struct { + /*File name to download + + */ + ContentDisposition string `json:"Content-Disposition"` + + /* + In: Body + */ + Payload io.ReadCloser `json:"body,omitempty"` +} + +// NewBulkDownloadOK creates BulkDownloadOK with default headers values +func NewBulkDownloadOK() *BulkDownloadOK { + + return &BulkDownloadOK{} +} + +// WithContentDisposition adds the contentDisposition to the bulk download o k response +func (o *BulkDownloadOK) WithContentDisposition(contentDisposition string) *BulkDownloadOK { + o.ContentDisposition = contentDisposition + return o +} + +// SetContentDisposition sets the contentDisposition to the bulk download o k response +func (o *BulkDownloadOK) SetContentDisposition(contentDisposition string) { + o.ContentDisposition = contentDisposition +} + +// WithPayload adds the payload to the bulk download o k response +func (o *BulkDownloadOK) WithPayload(payload io.ReadCloser) *BulkDownloadOK { + o.Payload = payload + return o +} + +// SetPayload sets the payload to the bulk download o k response +func (o *BulkDownloadOK) SetPayload(payload io.ReadCloser) { + o.Payload = payload +} + +// WriteResponse to the client +func (o *BulkDownloadOK) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + // response header Content-Disposition + + contentDisposition := o.ContentDisposition + if contentDisposition != "" { + rw.Header().Set("Content-Disposition", contentDisposition) + } + + rw.WriteHeader(200) + payload := o.Payload + if err := producer.Produce(rw, payload); err != nil { + panic(err) // let the recovery middleware deal with this + } +} + +// BulkDownloadBadRequestCode is the HTTP code returned for type BulkDownloadBadRequest +const BulkDownloadBadRequestCode int = 400 + +/* +BulkDownloadBadRequest The request payload is invalid + +swagger:response bulkDownloadBadRequest +*/ +type BulkDownloadBadRequest struct { + + /* + In: Body + */ + Payload *ghcmessages.Error `json:"body,omitempty"` +} + +// NewBulkDownloadBadRequest creates BulkDownloadBadRequest with default headers values +func NewBulkDownloadBadRequest() *BulkDownloadBadRequest { + + return &BulkDownloadBadRequest{} +} + +// WithPayload adds the payload to the bulk download bad request response +func (o *BulkDownloadBadRequest) WithPayload(payload *ghcmessages.Error) *BulkDownloadBadRequest { + o.Payload = payload + return o +} + +// SetPayload sets the payload to the bulk download bad request response +func (o *BulkDownloadBadRequest) SetPayload(payload *ghcmessages.Error) { + o.Payload = payload +} + +// WriteResponse to the client +func (o *BulkDownloadBadRequest) WriteResponse(rw http.ResponseWriter, producer runtime.Producer) { + + rw.WriteHeader(400) + if o.Payload != nil { + payload := o.Payload + if err := producer.Produce(rw, payload); err != nil { + panic(err) // let the recovery middleware deal with this + } + } +} + +// BulkDownloadInternalServerErrorCode is the HTTP code returned for type BulkDownloadInternalServerError +const BulkDownloadInternalServerErrorCode int = 500 + +/* +BulkDownloadInternalServerError A server error occurred + +swagger:response bulkDownloadInternalServerError +*/ +type BulkDownloadInternalServerError struct { + + /* + In: Body + */ + Payload *ghcmessages.Error `json:"body,omitempty"` +} + +// NewBulkDownloadInternalServerError creates BulkDownloadInternalServerError with default headers values +func NewBulkDownloadInternalServerError() *BulkDownloadInternalServerError { + + return &BulkDownloadInternalServerError{} +} + +// WithPayload adds the payload to the bulk download internal server error response +func (o *BulkDownloadInternalServerError) WithPayload(payload *ghcmessages.Error) *BulkDownloadInternalServerError { + o.Payload = payload + return o +} + +// SetPayload sets the payload to the bulk download internal server error response +func (o *BulkDownloadInternalServerError) SetPayload(payload *ghcmessages.Error) { + o.Payload = payload +} + +// WriteResponse to the client +func (o *BulkDownloadInternalServerError) 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/payment_requests/bulk_download_urlbuilder.go b/pkg/gen/ghcapi/ghcoperations/payment_requests/bulk_download_urlbuilder.go new file mode 100644 index 00000000000..7f67d894191 --- /dev/null +++ b/pkg/gen/ghcapi/ghcoperations/payment_requests/bulk_download_urlbuilder.go @@ -0,0 +1,99 @@ +// Code generated by go-swagger; DO NOT EDIT. + +package payment_requests + +// 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" + "strings" +) + +// BulkDownloadURL generates an URL for the bulk download operation +type BulkDownloadURL struct { + PaymentRequestID string + + _basePath string + // avoid unkeyed usage + _ struct{} +} + +// 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 *BulkDownloadURL) WithBasePath(bp string) *BulkDownloadURL { + 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 *BulkDownloadURL) SetBasePath(bp string) { + o._basePath = bp +} + +// Build a url path and query string +func (o *BulkDownloadURL) Build() (*url.URL, error) { + var _result url.URL + + var _path = "/payment-requests/{paymentRequestID}/bulkDownload" + + paymentRequestID := o.PaymentRequestID + if paymentRequestID != "" { + _path = strings.Replace(_path, "{paymentRequestID}", paymentRequestID, -1) + } else { + return nil, errors.New("paymentRequestId is required on BulkDownloadURL") + } + + _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 *BulkDownloadURL) 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 *BulkDownloadURL) String() string { + return o.Must(o.Build()).String() +} + +// BuildFull builds a full url with scheme, host, path and query string +func (o *BulkDownloadURL) BuildFull(scheme, host string) (*url.URL, error) { + if scheme == "" { + return nil, errors.New("scheme is required for a full url on BulkDownloadURL") + } + if host == "" { + return nil, errors.New("host is required for a full url on BulkDownloadURL") + } + + 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 *BulkDownloadURL) StringFull(scheme, host string) string { + return o.Must(o.BuildFull(scheme, host)).String() +} diff --git a/pkg/handlers/ghcapi/api.go b/pkg/handlers/ghcapi/api.go index 29e8f58963d..eb3bafeceb6 100644 --- a/pkg/handlers/ghcapi/api.go +++ b/pkg/handlers/ghcapi/api.go @@ -671,6 +671,12 @@ func NewGhcAPIHandler(handlerConfig handlers.HandlerConfig) *ghcops.MymoveAPI { move.NewMoveCanceler(), } + paymentRequestBulkDownloadCreator := paymentrequest.NewPaymentRequestBulkDownloadCreator(pdfGenerator) + ghcAPI.PaymentRequestsBulkDownloadHandler = PaymentRequestBulkDownloadHandler{ + handlerConfig, + paymentRequestBulkDownloadCreator, + } + dateSelectionChecker := dateservice.NewDateSelectionChecker() ghcAPI.CalendarIsDateWeekendHolidayHandler = IsDateWeekendHolidayHandler{handlerConfig, dateSelectionChecker} diff --git a/pkg/handlers/ghcapi/payment_request.go b/pkg/handlers/ghcapi/payment_request.go index 757993d2669..622d38b1abc 100644 --- a/pkg/handlers/ghcapi/payment_request.go +++ b/pkg/handlers/ghcapi/payment_request.go @@ -2,6 +2,7 @@ package ghcapi import ( "fmt" + "io" "reflect" "time" @@ -261,3 +262,39 @@ func (h ShipmentsSITBalanceHandler) Handle( return paymentrequestop.NewGetShipmentsPaymentSITBalanceOK().WithPayload(payload), nil }) } + +type PaymentRequestBulkDownloadHandler struct { + handlers.HandlerConfig + services.PaymentRequestBulkDownloadCreator +} + +func (h PaymentRequestBulkDownloadHandler) Handle(params paymentrequestop.BulkDownloadParams) middleware.Responder { + return h.AuditableAppContextFromRequestWithErrors(params.HTTPRequest, + func(appCtx appcontext.AppContext) (middleware.Responder, error) { + logger := appCtx.Logger() + + paymentRequestID, err := uuid.FromString(params.PaymentRequestID) + if err != nil { + errInstance := fmt.Sprintf("Instance: %s", h.GetTraceIDFromRequest(params.HTTPRequest)) + + errPayload := &ghcmessages.Error{Message: &errInstance} + + appCtx.Logger().Error(err.Error()) + return paymentrequestop.NewBulkDownloadBadRequest().WithPayload(errPayload), err + } + + paymentRequestPacket, err := h.PaymentRequestBulkDownloadCreator.CreatePaymentRequestBulkDownload(appCtx, paymentRequestID) + if err != nil { + logger.Error("Error creating Payment Request Downloads Packet", zap.Error(err)) + errInstance := fmt.Sprintf("Instance: %s", h.GetTraceIDFromRequest(params.HTTPRequest)) + errPayload := &ghcmessages.Error{Message: &errInstance} + return paymentrequestop.NewBulkDownloadInternalServerError(). + WithPayload(errPayload), err + } + + payload := io.NopCloser(paymentRequestPacket) + filename := fmt.Sprintf("inline; filename=\"PaymentRequestBulkPacket-%s.pdf\"", time.Now().Format("01-02-2006_15-04-05")) + + return paymentrequestop.NewBulkDownloadOK().WithContentDisposition(filename).WithPayload(payload), nil + }) +} diff --git a/pkg/paperwork/generator.go b/pkg/paperwork/generator.go index 881f58c454a..23ee0accdc3 100644 --- a/pkg/paperwork/generator.go +++ b/pkg/paperwork/generator.go @@ -213,7 +213,7 @@ func (g *Generator) GetPdfFileInfoByContents(file afero.File) (*pdfcpu.PDFInfo, // CreateMergedPDFUpload converts Uploads to PDF and merges them into a single PDF func (g *Generator) CreateMergedPDFUpload(appCtx appcontext.AppContext, uploads models.Uploads) (afero.File, error) { - pdfs, err := g.ConvertUploadsToPDF(appCtx, uploads) + pdfs, err := g.ConvertUploadsToPDF(appCtx, uploads, true) if err != nil { return nil, errors.Wrap(err, "Error while converting uploads") } @@ -227,7 +227,7 @@ func (g *Generator) CreateMergedPDFUpload(appCtx appcontext.AppContext, uploads } // ConvertUploadsToPDF turns a slice of Uploads into a slice of paths to converted PDF files -func (g *Generator) ConvertUploadsToPDF(appCtx appcontext.AppContext, uploads models.Uploads) ([]string, error) { +func (g *Generator) ConvertUploadsToPDF(appCtx appcontext.AppContext, uploads models.Uploads, doRotation bool) ([]string, error) { // tempfile paths to be returned pdfs := make([]string, 0) @@ -240,9 +240,18 @@ func (g *Generator) ConvertUploadsToPDF(appCtx appcontext.AppContext, uploads mo if len(images) > 0 { // We want to retain page order and will generate a PDF for images // that have already been encountered before handling this PDF. - pdf, err := g.PDFFromImages(appCtx, images) - if err != nil { - return nil, errors.Wrap(err, "Converting images") + var pdf string + var err error + if doRotation { + pdf, err = g.PDFFromImages(appCtx, images) + if err != nil { + return nil, errors.Wrap(err, "Converting images") + } + } else { + pdf, err = g.PDFFromImagesNoRotation(appCtx, images) + if err != nil { + return nil, errors.Wrap(err, "Converting images") + } } pdfs = append(pdfs, pdf) images = make([]inputFile, 0) @@ -282,9 +291,19 @@ func (g *Generator) ConvertUploadsToPDF(appCtx appcontext.AppContext, uploads mo // Merge all remaining images in urls into a new PDF if len(images) > 0 { - pdf, err := g.PDFFromImages(appCtx, images) - if err != nil { - return nil, errors.Wrap(err, "Converting remaining images to pdf") + var pdf string + var err error + + if doRotation { + pdf, err = g.PDFFromImages(appCtx, images) + if err != nil { + return nil, errors.Wrap(err, "Converting remaining images to pdf") + } + } else { + pdf, err = g.PDFFromImagesNoRotation(appCtx, images) + if err != nil { + return nil, errors.Wrap(err, "Converting remaining images to pdf") + } } pdfs = append(pdfs, pdf) } @@ -514,6 +533,115 @@ func (g *Generator) PDFFromImages(appCtx appcontext.AppContext, images []inputFi return outputFile.Name(), nil } +// PDFFromImages returns the path to tempfile PDF containing all images included +// in urls. +// +// The files at those paths will be tempfiles that will need to be cleaned +// up by the caller. +func (g *Generator) PDFFromImagesNoRotation(appCtx appcontext.AppContext, images []inputFile) (string, error) { + // These constants are based on A4 page size, which we currently default to. + horizontalMargin := 0.0 + topMargin := 0.0 + bodyWidth := PdfPageWidth - (horizontalMargin * 2) + bodyHeight := PdfPageHeight - (topMargin * 2) + wToHRatio := bodyWidth / bodyHeight + + pdf := gofpdf.New(PdfOrientation, PdfUnit, PdfPageSize, PdfFontDir) + pdf.SetMargins(horizontalMargin, topMargin, horizontalMargin) + + if len(images) == 0 { + return "", errors.New("No images provided") + } + + appCtx.Logger().Debug("generating PDF from image files", zap.Any("images", images)) + + outputFile, err := g.newTempFile() + if err != nil { + return "", err + } + + defer func() { + if closeErr := outputFile.Close(); closeErr != nil { + appCtx.Logger().Debug("Failed to close file", zap.Error(closeErr)) + } + }() + + var opt gofpdf.ImageOptions + for _, img := range images { + pdf.AddPage() + file, openErr := g.fs.Open(img.Path) + if openErr != nil { + return "", errors.Wrap(openErr, "Opening image file") + } + + defer func() { + if closeErr := file.Close(); closeErr != nil { + appCtx.Logger().Debug("Failed to close file", zap.Error(closeErr)) + } + }() + + if img.ContentType == uploader.FileTypePNG { + appCtx.Logger().Debug("Converting png to 8-bit") + // gofpdf isn't able to process 16-bit PNGs, so to be safe we convert all PNGs to an 8-bit color depth + newFile, newTemplateFileErr := g.newTempFile() + if newTemplateFileErr != nil { + return "", errors.Wrap(newTemplateFileErr, "Creating temp file for png conversion") + } + + defer func() { + if closeErr := newFile.Close(); closeErr != nil { + appCtx.Logger().Debug("Failed to close file", zap.Error(closeErr)) + } + }() + + convertTo8BitPNGErr := convertTo8BitPNG(file, newFile) + if convertTo8BitPNGErr != nil { + return "", errors.Wrap(convertTo8BitPNGErr, "Converting to 8-bit png") + } + file = newFile + _, fileSeekErr := file.Seek(0, io.SeekStart) + if fileSeekErr != nil { + return "", errors.Wrapf(fileSeekErr, "file.Seek offset: 0 whence: %d", io.SeekStart) + } + } + + widthInPdf := bodyWidth + heightInPdf := 0.0 + + // Scale using the imageOptions below + // BodyWidth should be set to 0 when the image height the proportion of the page + // is taller than wide as compared to an A4 page. + // + // The opposite is true and defaulted for when the image is wider than it is tall, + // in comparison to an A4 page. + if float64(bodyWidth/bodyHeight) < wToHRatio { + widthInPdf = 0 + heightInPdf = bodyHeight + } + + // Seek to the beginning of the file so when we register the image, it doesn't start + // at the end of the file. + _, fileSeekErr := file.Seek(0, io.SeekStart) + if fileSeekErr != nil { + return "", errors.Wrapf(fileSeekErr, "file.Seek offset: 0 whence: %d", io.SeekStart) + } + // Need to register the image using an afero reader, else it uses default filesystem + pdf.RegisterImageReader(img.Path, contentTypeToImageType[img.ContentType], file) + opt.ImageType = contentTypeToImageType[img.ContentType] + + pdf.ImageOptions(img.Path, horizontalMargin, topMargin, widthInPdf, heightInPdf, false, opt, 0, "") + fileCloseErr := file.Close() + if fileCloseErr != nil { + return "", errors.Wrapf(err, "error closing file: %s", file.Name()) + } + } + + if err = pdf.OutputAndClose(outputFile); err != nil { + return "", errors.Wrap(err, "could not write PDF to outputfile") + } + return outputFile.Name(), nil +} + // MergePDFFiles Merges a slice of paths to PDF files into a single PDF func (g *Generator) MergePDFFiles(_ appcontext.AppContext, paths []string) (afero.File, error) { var err error diff --git a/pkg/paperwork/generator_test.go b/pkg/paperwork/generator_test.go index 8b0eddea933..775c3207696 100644 --- a/pkg/paperwork/generator_test.go +++ b/pkg/paperwork/generator_test.go @@ -143,6 +143,65 @@ func (suite *PaperworkSuite) TestPDFFromImages() { suite.Contains(checksums, orders2Checksum, "did not find hash for orders2.jpg") } +func (suite *PaperworkSuite) TestPDFFromImagesNoRotation() { + generator, newGeneratorErr := NewGenerator(suite.userUploader.Uploader()) + suite.FatalNil(newGeneratorErr) + + images := []inputFile{ + {Path: "testdata/orders1.jpg", ContentType: uploader.FileTypeJPEG}, + {Path: "testdata/orders2.jpg", ContentType: uploader.FileTypeJPEG}, + } + for _, image := range images { + _, err := suite.openLocalFile(image.Path, generator.fs) + suite.FatalNil(err) + } + + generatedPath, err := generator.PDFFromImagesNoRotation(suite.AppContextForTest(), images) + suite.FatalNil(err, "failed to generate pdf") + aferoFile, err := generator.fs.Open(generatedPath) + suite.FatalNil(err, "afero failed to open pdf") + + suite.NotEmpty(generatedPath, "got an empty path to the generated file") + suite.FatalNil(err) + + // verify that the images are in the pdf by extracting them and checking their checksums + file, err := afero.ReadAll(aferoFile) + suite.FatalNil(err) + tmpDir, err := os.MkdirTemp("", "images") + suite.FatalNil(err) + f, err := os.CreateTemp(tmpDir, "") + suite.FatalNil(err) + err = os.WriteFile(f.Name(), file, os.ModePerm) + suite.FatalNil(err) + err = api.ExtractImagesFile(f.Name(), tmpDir, []string{"-2"}, generator.pdfConfig) + suite.FatalNil(err) + err = os.Remove(f.Name()) + suite.FatalNil(err) + + checksums := make([]string, 2) + files, err := os.ReadDir(tmpDir) + suite.FatalNil(err) + + suite.Equal(4, len(files), "did not find 2 images") + + for _, file := range files { + checksum, sha256ForPathErr := suite.sha256ForPath(path.Join(tmpDir, file.Name()), nil) + suite.FatalNil(sha256ForPathErr, "error calculating hash") + if sha256ForPathErr != nil { + suite.FailNow(sha256ForPathErr.Error()) + } + checksums = append(checksums, checksum) + } + + orders1Checksum, err := suite.sha256ForPath("testdata/orders1.jpg", generator.fs) + suite.Nil(err, "error calculating hash") + suite.Contains(checksums, orders1Checksum, "did not find hash for orders1.jpg") + + orders2Checksum, err := suite.sha256ForPath("testdata/orders2.jpg", generator.fs) + suite.Nil(err, "error calculating hash") + suite.Contains(checksums, orders2Checksum, "did not find hash for orders2.jpg") +} + func (suite *PaperworkSuite) TestPDFFromImages16BitPNG() { generator, err := NewGenerator(suite.userUploader.Uploader()) suite.FatalNil(err) @@ -187,7 +246,7 @@ func (suite *PaperworkSuite) TestGenerateUploadsPDF() { uploads, err := models.UploadsFromUserUploads(suite.DB(), order.UploadedOrders.UserUploads) suite.FatalNil(err) - paths, err := generator.ConvertUploadsToPDF(suite.AppContextForTest(), uploads) + paths, err := generator.ConvertUploadsToPDF(suite.AppContextForTest(), uploads, true) suite.FatalNil(err) suite.Equal(3, len(paths), "wrong number of paths returned") diff --git a/pkg/services/payment_request.go b/pkg/services/payment_request.go index 5f81f2215c9..25f62be43a4 100644 --- a/pkg/services/payment_request.go +++ b/pkg/services/payment_request.go @@ -5,6 +5,7 @@ import ( "time" "github.com/gofrs/uuid" + "github.com/spf13/afero" "github.com/transcom/mymove/pkg/appcontext" "github.com/transcom/mymove/pkg/models" @@ -115,3 +116,7 @@ type ShipmentPaymentSITBalance struct { type ShipmentsPaymentSITBalance interface { ListShipmentPaymentSITBalance(appCtx appcontext.AppContext, paymentRequestID uuid.UUID) ([]ShipmentPaymentSITBalance, error) } + +type PaymentRequestBulkDownloadCreator interface { + CreatePaymentRequestBulkDownload(appCtx appcontext.AppContext, paymentRequestID uuid.UUID) (afero.File, error) +} diff --git a/pkg/services/payment_request/payment_request_bulk_download_creator.go b/pkg/services/payment_request/payment_request_bulk_download_creator.go new file mode 100644 index 00000000000..1178f4c991c --- /dev/null +++ b/pkg/services/payment_request/payment_request_bulk_download_creator.go @@ -0,0 +1,57 @@ +package paymentrequest + +import ( + "fmt" + + "github.com/gofrs/uuid" + "github.com/spf13/afero" + + "github.com/transcom/mymove/pkg/appcontext" + "github.com/transcom/mymove/pkg/models" + "github.com/transcom/mymove/pkg/paperwork" + "github.com/transcom/mymove/pkg/services" +) + +type paymentRequestBulkDownloadCreator struct { + pdfGenerator *paperwork.Generator +} + +func NewPaymentRequestBulkDownloadCreator(pdfGenerator *paperwork.Generator) services.PaymentRequestBulkDownloadCreator { + return &paymentRequestBulkDownloadCreator{ + pdfGenerator, + } +} + +func (p *paymentRequestBulkDownloadCreator) CreatePaymentRequestBulkDownload(appCtx appcontext.AppContext, paymentRequestID uuid.UUID) (afero.File, error) { + errMsgPrefix := "error creating Payment Request packet" + + paymentRequest := models.PaymentRequest{} + err := appCtx.DB().Q().Eager( + "MoveTaskOrder", + "ProofOfServiceDocs", + "ProofOfServiceDocs.PrimeUploads", + "ProofOfServiceDocs.PrimeUploads.Upload", + ).Find(&paymentRequest, paymentRequestID) + if err != nil || len(paymentRequest.ProofOfServiceDocs) < 1 { + return nil, fmt.Errorf("%s: %w", errMsgPrefix, err) + } + + var primeUploads models.Uploads + for _, serviceDoc := range paymentRequest.ProofOfServiceDocs { + for _, upload := range serviceDoc.PrimeUploads { + primeUploads = append(primeUploads, upload.Upload) + } + } + + pdfs, err := p.pdfGenerator.ConvertUploadsToPDF(appCtx, primeUploads, false) + if err != nil { + return nil, fmt.Errorf("%s error generating pdf", err) + } + + pdfFile, err := p.pdfGenerator.MergePDFFiles(appCtx, pdfs) + if err != nil { + return nil, fmt.Errorf("%s error generating merged pdf", err) + } + + return pdfFile, nil +} diff --git a/src/components/DocumentViewer/DocumentViewer.jsx b/src/components/DocumentViewer/DocumentViewer.jsx index 98494e3f163..73daff482d3 100644 --- a/src/components/DocumentViewer/DocumentViewer.jsx +++ b/src/components/DocumentViewer/DocumentViewer.jsx @@ -12,9 +12,10 @@ import Menu from './Menu/Menu'; import { milmoveLogger } from 'utils/milmoveLog'; import { UPLOADS } from 'constants/queryKeys'; -import { updateUpload } from 'services/ghcApi'; +import { bulkDownloadPaymentRequest, updateUpload } from 'services/ghcApi'; import { formatDate } from 'shared/dates'; import { filenameFromPath } from 'utils/formatters'; +import AsyncPacketDownloadLink from 'shared/AsyncPacketDownloadLink/AsyncPacketDownloadLink'; /** * TODO @@ -23,7 +24,7 @@ import { filenameFromPath } from 'utils/formatters'; * - handle fetch doc errors */ -const DocumentViewer = ({ files, allowDownload }) => { +const DocumentViewer = ({ files, allowDownload, paymentRequestId }) => { const [selectedFileIndex, selectFile] = useState(0); const [disableSaveButton, setDisableSaveButton] = useState(false); const [menuIsOpen, setMenuOpen] = useState(false); @@ -117,6 +118,20 @@ const DocumentViewer = ({ files, allowDownload }) => { } }; + const paymentPacketDownload = ( +
+
+

+ +

+
+
+ ); + return (
@@ -133,6 +148,7 @@ const DocumentViewer = ({ files, allowDownload }) => {

)} + {paymentRequestId !== undefined ? paymentPacketDownload : null}
{ const container = document.querySelector('[data-testid="menuButtonContainer"]'); if (container) { @@ -50,6 +52,11 @@ const mockFiles = [ }, ]; +jest.mock('services/ghcApi', () => ({ + ...jest.requireActual('services/ghcApi'), + bulkDownloadPaymentRequest: jest.fn(), +})); + jest.mock('./Content/Content', () => ({ __esModule: true, default: ({ id, filename, contentType, url, createdAt, rotation }) => ( @@ -172,4 +179,37 @@ describe('DocumentViewer component', () => { expect(screen.getByText('id: undefined')).toBeInTheDocument(); }); + + describe('when clicking download Download All Files button', () => { + it('downloads a bulk packet', async () => { + const mockResponse = { + ok: true, + headers: { + 'content-disposition': 'filename="test.pdf"', + }, + status: 200, + data: null, + }; + + render( + + + , + ); + + bulkDownloadPaymentRequest.mockImplementation(() => Promise.resolve(mockResponse)); + + const downloadButton = screen.getByText('Download All Files (PDF)', { exact: false }); + await userEvent.click(downloadButton); + await waitFor(() => { + expect(bulkDownloadPaymentRequest).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/src/pages/Office/PaymentRequestReview/PaymentRequestReview.jsx b/src/pages/Office/PaymentRequestReview/PaymentRequestReview.jsx index e4a3f2c576f..e20af2c740d 100644 --- a/src/pages/Office/PaymentRequestReview/PaymentRequestReview.jsx +++ b/src/pages/Office/PaymentRequestReview/PaymentRequestReview.jsx @@ -175,7 +175,11 @@ export const PaymentRequestReview = ({ order }) => { return (
- {uploads.length > 0 ? :

No documents provided

} + {uploads.length > 0 ? ( + + ) : ( +

No documents provided

+ )}
{ expect(reviewServiceItems.prop('serviceItemCards')).toEqual(expectedServiceItemCards); }); }); + describe('clicking the next button', () => { describe('with pending requests', () => { beforeEach(async () => { diff --git a/src/services/ghcApi.js b/src/services/ghcApi.js index 2537eddf30c..0692d3e02f4 100644 --- a/src/services/ghcApi.js +++ b/src/services/ghcApi.js @@ -848,6 +848,10 @@ export async function patchPPMSIT({ ppmShipmentId, payload, eTag }) { ); } +export async function bulkDownloadPaymentRequest(paymentRequestID) { + return makeGHCRequestRaw('paymentRequests.bulkDownload', { paymentRequestID }); +} + export async function dateSelectionIsWeekendHoliday(countryCode, date) { return makeGHCRequestRaw( 'calendar.isDateWeekendHoliday', diff --git a/swagger-def/ghc.yaml b/swagger-def/ghc.yaml index d3c21ee7371..4b60dc576c2 100644 --- a/swagger-def/ghc.yaml +++ b/swagger-def/ghc.yaml @@ -3141,6 +3141,36 @@ paths: summary: Updates status of a payment request by id x-permissions: - update.paymentRequest + '/payment-requests/{paymentRequestID}/bulkDownload': + parameters: + - description: the id for the payment-request with files to be downloaded + in: path + name: paymentRequestID + required: true + type: string + get: + summary: Downloads all Payment Request documents as a PDF + description: | + This endpoint downloads all uploaded payment request documentation combined into a single PDF. + operationId: bulkDownload + tags: + - paymentRequests + produces: + - application/pdf + responses: + '200': + headers: + Content-Disposition: + type: string + description: File name to download + description: Payment Request Files PDF + schema: + format: binary + type: file + '400': + $ref: '#/responses/InvalidRequest' + '500': + $ref: '#/responses/ServerError' /documents/{documentId}: get: summary: Returns a document diff --git a/swagger/ghc.yaml b/swagger/ghc.yaml index a0e7938d382..ce67ccb909d 100644 --- a/swagger/ghc.yaml +++ b/swagger/ghc.yaml @@ -3254,6 +3254,37 @@ paths: summary: Updates status of a payment request by id x-permissions: - update.paymentRequest + /payment-requests/{paymentRequestID}/bulkDownload: + parameters: + - description: the id for the payment-request with files to be downloaded + in: path + name: paymentRequestID + required: true + type: string + get: + summary: Downloads all Payment Request documents as a PDF + description: > + This endpoint downloads all uploaded payment request documentation + combined into a single PDF. + operationId: bulkDownload + tags: + - paymentRequests + produces: + - application/pdf + responses: + '200': + headers: + Content-Disposition: + type: string + description: File name to download + description: Payment Request Files PDF + schema: + format: binary + type: file + '400': + $ref: '#/responses/InvalidRequest' + '500': + $ref: '#/responses/ServerError' /documents/{documentId}: get: summary: Returns a document