18F/e-QIP-prototype

View on GitHub
api/pdf/pdf.go

Summary

Maintainability
A
0 mins
Test Coverage
package pdf

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "path"
    "regexp"
    "strconv"
    "strings"
    "time"

    "github.com/Jeffail/gabs"
    "github.com/pkg/errors"

    "github.com/18F/e-QIP-prototype/api"
)

// DocumentTypeCertification is the document type for the certification/signature-form
const DocumentTypeCertification = "CER"

// DocumentTypeCreditRelease is the document type for the credit release
const DocumentTypeCreditRelease = "FCR"

// DocumentTypeMedicalRelease is the document type for the medical release
const DocumentTypeMedicalRelease = "MEL"

// DocumentTypeInformationRelease is the document type for the infomation release
const DocumentTypeInformationRelease = "REL"

var (
    // ReleasePDFs lists the supported archival PDFs.
    ReleasePDFs = []api.ArchivalPdf{
        {"signature-form", "AdditionalComments", DocumentTypeCertification},
        {"release-credit", "Credit", DocumentTypeCreditRelease},
        {"release-medical", "Medical", DocumentTypeMedicalRelease},
        {"release-information", "General", DocumentTypeInformationRelease},
    }

    release86Filenames = map[string]string{
        "signature-form":      "certification-SF86-July2017.template.pdf",
        "release-credit":      "credit-SF86-July2017.template.pdf",
        "release-medical":     "medical-SF86-July2017.template.pdf",
        "release-information": "general-SF86-July2017.template.pdf",
    }

    release85Filenames = map[string]string{
        "signature-form":      "certification-SF85-December2017.pdf",
        "release-credit":      "credit-SF85-December2017.pdf",
        "release-information": "general-SF85-December2017.pdf",
    }

    releaseFilenames = map[string]map[string]string{
        "SF85": release85Filenames,
        "SF86": release86Filenames,
    }
)

var errSignatureNotAvailable = errors.New("This application has not been signed")

// field represents the fixed-width placeholder in an ArchivalPdf template.
type field struct {
    name     string // field identifier as it appears in the template
    length   int    // the fixed-width length of the field
    getValue func(*gabs.Container) string
}

// Service implements operations to create and query archival PDFs
type Service struct {
    templatePath string
}

// NewPDFService returns a new PDF service
func NewPDFService(templatePath string) Service {
    return Service{
        templatePath,
    }
}

// GenerateReleases generates the four signed pdfs for a given Application
func (service Service) GenerateReleases(account api.Account, app api.Application) ([]api.Attachment, error) {
    // TODO: Add the releases for the SF85P
    if app.FormType() == "SF85P" {
        return []api.Attachment{}, errors.New("releases for the SF85P have not been implmented yet. Unable to generate releases")
    }

    // Same as for XML, we convert the applicaiton to a raw JSON form for templating
    // This can be cleaned up in the future, these functions should all work on the model objects instead of JSON
    jsonBytes, jsonErr := json.Marshal(app)
    if jsonErr != nil {
        return []api.Attachment{}, errors.Wrap(jsonErr, "Unable to marshal application")
    }

    var appData map[string]interface{}
    unmarhalErr := json.Unmarshal(jsonBytes, &appData)
    if unmarhalErr != nil {
        return []api.Attachment{}, errors.Wrap(unmarhalErr, "Unable to re-un-marshal application")
    }

    hash, hashErr := app.Hash()
    if hashErr != nil {
        return []api.Attachment{}, errors.Wrap(hashErr, "Unable to hash application")
    }

    attachments := []api.Attachment{}

    for _, docInfo := range ReleasePDFs {

        docBytes, createErr := service.CreatePdf(appData, docInfo, hash, app.FormType())
        if createErr != nil {
            if createErr == errSignatureNotAvailable {
                continue
            }
            return []api.Attachment{}, errors.Wrap(createErr, "Unable to create document")
        }

        attachment := api.Attachment{
            AccountID: account.ID,
            Filename:  fmt.Sprintf("%s.%s.pdf", docInfo.Name, account.ExternalID),
            Size:      int64(len(docBytes)),
            Raw:       docBytes,
            DocType:   docInfo.DocType,
        }

        attachments = append(attachments, attachment)
    }

    return attachments, nil
}

// CreatePdf creates an in-memory PDF from a template, populated with values from the application,
// using basic text subsitution. Field names (e.g., SSN) are replaced with application values,
// space padded to a fixed length to ensure that PDF object/xref byte offsets do not need to be
// updated. Assumes field values are ASCII.
// The type of PDF controls the template used. The SHA256 hash of the application, in hexadecimal,
// may also be populated in the resulting PDF.
func (service Service) CreatePdf(application map[string]interface{}, pdfType api.ArchivalPdf, hash string, formType string) ([]byte, error) {
    // Get application data into easily queryable form
    json, _ := gabs.Consume(application)

    if !hasSignedOn(json, pdfType.Section) {
        return []byte{}, errSignatureNotAvailable
    }

    signedOn := func(json *gabs.Container) string {
        return getSignedOn(json, pdfType.Section)
    }

    hexHash := func(json *gabs.Container) string {
        return hash
    }

    // Field widths are based on monospaced font and fixed point size
    // used in PDF templates and their layout.
    fields := []field{
        {"SSN", 11, getSsn},
        {"FIRST_MIDDLE_LAST", 64, getFullName},
        {"SIGNED_ON", 10, signedOn},
        {"OTHER_NAMES", 77, getOtherNames},
        {"CITY_COUNTRY", 26, getCityCountry},
        {"STATE", 6, getState},
        {"ZIP_CODE", 10, getZipCode},
        {"DOB", 10, getDob},
        {"TELEPHONE", 20, getTelephone},
        {"STREET_ADDRESS", 40, getStreetAddress},
        {"APPLICATION_SHA256_HASH", 64, hexHash},
    }

    templatePath := releaseFilenames[formType][pdfType.Name]

    dat, err := ioutil.ReadFile(path.Join(service.templatePath, templatePath))
    if err != nil {
        return nil, err
    }
    str := string(dat)

    for _, f := range fields {
        // PDF template field names are followed by one or more spaces
        re := f.name + "  *"

        // Pad field values with spaces to preserve PDF object/xref byte offsets
        format := "%-" + strconv.Itoa(f.length) + "s"

        str = regexp.MustCompile(re).ReplaceAllString(str, fmt.Sprintf(format, f.getValue(json)))
    }

    return []byte(str), nil
}

func hasSignedOn(json *gabs.Container, docSubsection string) bool {
    d := getSignedOnParts(json, docSubsection)

    // Ensure all date parts are non-empty
    if !(len(d) == 3 && d[0] != "" && d[1] != "" && d[2] != "") {
        return false
    }
    return true
}

// SignatureAvailable returns the date the eApp section corresponding to the PDF type was signed.
// If the section was not signed (e.g., no responses requiring medical release), returns nil and false.
func (service Service) SignatureAvailable(application map[string]interface{}, pdfType api.ArchivalPdf) (*time.Time, bool) {
    json, _ := gabs.Consume(application)
    d := getSignedOnParts(json, pdfType.Section)
    // Ensure all date parts are non-empty
    if len(d) == 3 && d[0] != "" && d[1] != "" && d[2] != "" {
        year, _ := strconv.Atoi(d[2])
        month, _ := strconv.Atoi(d[0])
        day, _ := strconv.Atoi(d[1])

        signedOn := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
        return &signedOn, true
    }
    return nil, false
}

func getPath(json *gabs.Container, path string) string {
    value, _ := json.Path(path).Data().(string)
    return value
}

func getPaths(json *gabs.Container, prefix string, keys []string) []string {
    if prefix[len(prefix)-1:] != "." {
        prefix = prefix + "."
    }

    values := []string{}
    for _, k := range keys {
        values = append(values, getPath(json, prefix+k))
    }
    return values
}

// Location fields are one of few structures that are not persisted
// in JSON structure or database if they are empty; see location.go.
func getFullAddress(json *gabs.Container) (*gabs.Container, error) {
    path := "History.Residence.props.List.props.items"
    children, _ := json.Path(path).Children()
    for _, child := range children {
        present, _ := child.Path("Item.Dates.props.present").Data().(bool)
        if present == true {
            return child, nil
        }
    }
    return nil, errors.New("No address indicated as `present`")
}

func getStreetAddress(json *gabs.Container) string {
    full, err := getFullAddress(json)
    if err != nil {
        return ""
    }

    street := getPath(full, "Item.Address.props.street")
    street2 := getPath(full, "Item.Address.props.street2")

    if street2 != "" {
        street = street + ", " + street2
    }

    return street
}

func getTelephone(json *gabs.Container) string {
    path := "Identification.Contacts.props.PhoneNumbers.props.items"
    children, _ := json.Path(path).Children()
    for _, child := range children {
        // Return the first telephone phone number in the list

        prefix := "Item.Telephone.props."
        numberType := getPath(child, prefix+"type")
        number := getPath(child, prefix+"number")
        extension := getPath(child, prefix+"extension")
        if extension != "" {
            extension = " x" + extension
        }

        if numberType != "Domestic" {
            return number + extension
        }

        return fmt.Sprintf("(%s) %s-%s%s",
            number[0:3], number[3:6], number[6:], extension)
    }

    return ""
}

func getDob(json *gabs.Container) string {
    prefix := "Identification.ApplicantBirthDate.props.Date.props"
    keys := []string{"month", "day", "year"}
    values := getPaths(json, prefix, keys)

    return strings.Join(values, "/")
}

func getZipCode(json *gabs.Container) string {
    full, err := getFullAddress(json)
    if err != nil {
        return ""
    }

    return getPath(full, "Item.Address.props.zipcode")
}

func getState(json *gabs.Container) string {
    full, err := getFullAddress(json)
    if err != nil {
        return ""
    }
    return getPath(full, "Item.Address.props.state")
}

func getCityCountry(json *gabs.Container) string {
    full, err := getFullAddress(json)
    if err != nil {
        return ""
    }

    city := getPath(full, "Item.Address.props.city")
    country := getPath(full, "Item.Address.props.country")

    if country == "" || country == "United States" {
        return city
    }
    return city + ", " + country
}

func getOtherNames(json *gabs.Container) string {
    names := []string{}

    path := "Identification.OtherNames.props.List.props.items"
    children, _ := json.Path(path).Children()
    for _, child := range children {
        keys := []string{"first", "middle", "last"}
        values := getPaths(child, "Item.Name.props", keys)
        n := strings.Join(values, " ")
        names = append(names, n)
    }
    return strings.Join(names, ", ")
}

func getSignedOnParts(json *gabs.Container, docSubsection string) []string {
    prefix := "Submission.Releases.props." + docSubsection + ".props.Signature.props.Date.props"
    keys := []string{"month", "day", "year"}
    return getPaths(json, prefix, keys)
}

func getSignedOn(json *gabs.Container, docSubsection string) string {
    values := getSignedOnParts(json, docSubsection)
    return strings.Join(values, "/")
}

func getSsn(json *gabs.Container) string {
    prefix := "Identification.ApplicantSSN.props.ssn.props"
    keys := []string{"first", "middle", "last"}
    values := getPaths(json, prefix, keys)

    return strings.Join(values, "-")
}

func getFullName(json *gabs.Container) string {
    prefix := "Identification.ApplicantName.props.Name.props"
    keys := []string{"first", "middle", "last"}
    values := getPaths(json, prefix, keys)

    suffix := getPath(json, prefix+"suffix")
    if suffix == "Other" {
        suffix = getPath(json, prefix+"suffixOther")
    }
    if suffix != "" {
        values = append(values, suffix)
    }

    return strings.Join(values, " ")
}