From 348c896e89f5dd2a0d74e0c4cb41ad209f34d042 Mon Sep 17 00:00:00 2001 From: nic Date: Thu, 3 Apr 2025 15:53:42 -0400 Subject: [PATCH] feat: completed upload ability for jobs --- internal/api/attachments.go | 107 ++++++++++-- internal/handlers/web/documents.go | 169 ++++++++++++++----- templates/partials/csv_upload.html | 2 +- templates/partials/document_upload.html | 35 +++- templates/partials/document_upload_form.html | 12 +- templates/partials/upload_actions.html | 3 +- 6 files changed, 260 insertions(+), 68 deletions(-) diff --git a/internal/api/attachments.go b/internal/api/attachments.go index c9d7ce9..6429849 100644 --- a/internal/api/attachments.go +++ b/internal/api/attachments.go @@ -8,7 +8,44 @@ import ( "log" "mime/multipart" "net/http" - "path/filepath" + "strconv" + "strings" +) + +// ServiceTrade attachment purpose constants with file type restrictions +const ( + // AttachmentPurposeJobPaperwork - PDF documents accepted + AttachmentPurposeJobPaperwork = 1 + + // AttachmentPurposeJobVendorBill - PDF documents accepted + AttachmentPurposeJobVendorBill = 2 + + // AttachmentPurposeJobPicture - Only image files: *.gif, *.jpeg, *.jpg, *.png, *.tif, *.tiff + AttachmentPurposeJobPicture = 3 + + // AttachmentPurposeDeficiencyRepairProposal - PDF documents accepted + AttachmentPurposeDeficiencyRepairProposal = 5 + + // AttachmentPurposeGenericAttachment - PDF documents accepted + AttachmentPurposeGenericAttachment = 7 + + // AttachmentPurposeAvatarImage - Only image files: *.gif, *.jpeg, *.jpg, *.png, *.tif, *.tiff + AttachmentPurposeAvatarImage = 8 + + // AttachmentPurposeImport - Only text files: *.csv, *.txt + AttachmentPurposeImport = 9 + + // AttachmentPurposeBlankPaperwork - PDF documents accepted + AttachmentPurposeBlankPaperwork = 10 + + // AttachmentPurposeWorkAcknowledgement - Only JSON files: *.json + AttachmentPurposeWorkAcknowledgement = 11 + + // AttachmentPurposeLogo - Only image files: *.gif, *.jpeg, *.jpg, *.png, *.tif, *.tiff + AttachmentPurposeLogo = 12 + + // AttachmentPurposeJobInvoice - PDF documents accepted + AttachmentPurposeJobInvoice = 14 ) // UploadAttachment uploads a file as an attachment to a job @@ -19,25 +56,69 @@ func (s *Session) UploadAttachment(jobID, filename, purpose string, fileContent var b bytes.Buffer w := multipart.NewWriter(&b) - // Add the purpose (attachment type) - if err := w.WriteField("purpose", purpose); err != nil { - return nil, fmt.Errorf("error writing purpose field: %v", err) + // Log received values + log.Printf("Uploading attachment to job ID: %s", jobID) + log.Printf("Filename: %s", filename) + log.Printf("Purpose value: '%s'", purpose) + log.Printf("File content length: %d bytes", len(fileContent)) + + // The ServiceTrade API expects the purpose ID as an integer + purposeStr := strings.TrimSpace(purpose) + + // Try to parse the purpose as an integer, removing any leading zeros first + purposeStr = strings.TrimLeft(purposeStr, "0") + if purposeStr == "" { + purposeStr = "0" // If only zeros were provided + } + + purposeInt, err := strconv.Atoi(purposeStr) + if err != nil { + return nil, fmt.Errorf("invalid purpose value '%s': must be a valid integer: %v", purpose, err) + } + + log.Printf("Using purpose value: %d for job: %s", purposeInt, jobID) + + // Add the purposeId (attachment type) as an integer + // NOTE: The API expects "purposeId", not "purpose" + if err := w.WriteField("purposeId", fmt.Sprintf("%d", purposeInt)); err != nil { + return nil, fmt.Errorf("error writing purposeId field: %v", err) + } + + // Add the entityType (3 for Job) and entityId (jobID) + if err := w.WriteField("entityType", "3"); err != nil { // 3 = Job + return nil, fmt.Errorf("error writing entityType field: %v", err) } - // Add the job ID - if err := w.WriteField("job", jobID); err != nil { - return nil, fmt.Errorf("error writing job field: %v", err) + if err := w.WriteField("entityId", jobID); err != nil { + return nil, fmt.Errorf("error writing entityId field: %v", err) } - // Add the file - fw, err := w.CreateFormFile("filename", filepath.Base(filename)) + // Ensure we have a file with content to upload + if len(fileContent) == 0 { + return nil, fmt.Errorf("no file content provided for upload") + } + + // Add a description field with the filename for better identification + if err := w.WriteField("description", filename); err != nil { + return nil, fmt.Errorf("error writing description field: %v", err) + } + + // Add the file - make sure we use the real filename with extension for content-type detection + // The API requires the file extension to determine the content type + if !strings.Contains(filename, ".") { + return nil, fmt.Errorf("filename must include an extension (e.g. .pdf, .docx) for API content type detection") + } + + fw, err := w.CreateFormFile("uploadedFile", filename) if err != nil { return nil, fmt.Errorf("error creating form file: %v", err) } - if _, err := io.Copy(fw, bytes.NewReader(fileContent)); err != nil { + bytesWritten, err := io.Copy(fw, bytes.NewReader(fileContent)) + if err != nil { return nil, fmt.Errorf("error copying file content: %v", err) } + log.Printf("Wrote %d bytes of file content to the form", bytesWritten) // Close the writer if err := w.Close(); err != nil { @@ -54,6 +135,11 @@ func (s *Session) UploadAttachment(jobID, filename, purpose string, fileContent req.Header.Set("Content-Type", w.FormDataContentType()) req.Header.Set("Cookie", s.Cookie) + // Debug information + log.Printf("Sending request to: %s", url) + log.Printf("Content-Type: %s", w.FormDataContentType()) + log.Printf("Request body fields: purposeId=%d, entityType=3, entityId=%s, filename=%s", purposeInt, jobID, filename) + // Send the request resp, err := s.Client.Do(req) if err != nil { @@ -69,6 +155,7 @@ func (s *Session) UploadAttachment(jobID, filename, purpose string, fileContent // Check for errors if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + log.Printf("API error response: %s - %s", resp.Status, string(body)) return nil, fmt.Errorf("API returned error: %s - %s", resp.Status, string(body)) } diff --git a/internal/handlers/web/documents.go b/internal/handlers/web/documents.go index 9f94371..9867fa2 100644 --- a/internal/handlers/web/documents.go +++ b/internal/handlers/web/documents.go @@ -8,6 +8,7 @@ import ( "log" "mime/multipart" "net/http" + "path/filepath" "regexp" "strconv" "strings" @@ -94,42 +95,100 @@ func ProcessCSVHandler(w http.ResponseWriter, r *http.Request) { return } + if len(csvData) < 2 { + http.Error(w, "CSV file must contain at least a header row and one data row", http.StatusBadRequest) + return + } + + // Find the index of the 'id' column + headerRow := csvData[0] + idColumnIndex := -1 + for i, header := range headerRow { + if strings.ToLower(strings.TrimSpace(header)) == "id" { + idColumnIndex = i + break + } + } + + // If 'id' column not found, try the first column + if idColumnIndex == -1 { + idColumnIndex = 0 + log.Printf("No 'id' column found in CSV, using first column (header: %s)", headerRow[0]) + } else { + log.Printf("Found 'id' column at index %d", idColumnIndex) + } + // Extract job numbers from the CSV var jobNumbers []string - for _, row := range csvData { - if len(row) > 0 && row[0] != "" { - // Trim whitespace and skip headers or empty lines - jobNum := strings.TrimSpace(row[0]) - if jobNum != "Job Number" && jobNum != "JobNumber" && jobNum != "Job" && jobNum != "" { - jobNumbers = append(jobNumbers, jobNum) + for rowIndex, row := range csvData { + // Skip header row + if rowIndex == 0 { + continue + } + + if len(row) > idColumnIndex { + // Extract and clean up the job ID + jobID := strings.TrimSpace(row[idColumnIndex]) + if jobID != "" { + jobNumbers = append(jobNumbers, jobID) + log.Printf("Added job ID: %s", jobID) } } } - // Validate each job number (optional: could make API calls to verify they exist) + log.Printf("Extracted %d job IDs from CSV", len(jobNumbers)) + + // Create a list of valid job numbers var validJobNumbers []string validJobNumbers = append(validJobNumbers, jobNumbers...) // Generate HTML for job list var jobListHTML string if len(validJobNumbers) > 0 { - // Add a hidden input with comma-separated job IDs for form submission - jobListHTML = fmt.Sprintf("", strings.Join(validJobNumbers, ",")) - jobListHTML += "" - // Add JavaScript to make the CSV preview visible - jobListHTML += "" + // Create a hidden input with the job IDs + jobsValue := strings.Join(validJobNumbers, ",") + + // Insert a hidden input for job numbers and show the job list + jobListHTML = fmt.Sprintf(` + + + + + + `, jobsValue, buildJobListJS(validJobNumbers)) } else { - jobListHTML = "

No valid job numbers found in the CSV file.

" + jobListHTML = ` +

No valid job numbers found in the CSV file.

+ ` } w.Header().Set("Content-Type", "text/html") w.Write([]byte(jobListHTML)) } +// Helper function to build JavaScript for job list +func buildJobListJS(jobIDs []string) string { + var js strings.Builder + for _, id := range jobIDs { + js.WriteString(fmt.Sprintf(` + var li = document.createElement("li"); + li.setAttribute("data-job-id", "%s"); + li.textContent = "Job #%s"; + ul.appendChild(li); + `, id, id)) + } + return js.String() +} + // UploadDocumentsHandler handles document uploads to jobs func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) { session, ok := r.Context().Value("session").(*api.Session) @@ -146,17 +205,25 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) { // Parse the multipart form with a 30MB limit if err := r.ParseMultipartForm(30 << 20); err != nil { - http.Error(w, "Unable to parse form: "+err.Error(), http.StatusBadRequest) + http.Error(w, fmt.Sprintf("Unable to parse form: %v", err), http.StatusBadRequest) return } - // Get the job numbers + // Get the job numbers from either of the possible form fields jobNumbers := r.FormValue("jobNumbers") if jobNumbers == "" { - http.Error(w, "No job numbers provided", http.StatusBadRequest) - return + jobNumbers = r.FormValue("job-ids") + if jobNumbers == "" { + log.Printf("No job numbers provided. Form data: %+v", r.Form) + http.Error(w, "No job numbers provided", http.StatusBadRequest) + return + } } + // Log the form data for debugging + log.Printf("Form data: %+v", r.Form) + log.Printf("Job numbers: %s", jobNumbers) + // Split the job numbers jobs := strings.Split(jobNumbers, ",") if len(jobs) == 0 { @@ -210,8 +277,18 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) { documentName := r.FormValue(nameKey) if documentName == "" { documentName = fileHeader.Filename + } else { + // If a custom name is provided without extension, add the original file extension + if !strings.Contains(documentName, ".") { + extension := filepath.Ext(fileHeader.Filename) + if extension != "" { + documentName = documentName + extension + } + } } + log.Printf("Using document name: %s (original filename: %s)", documentName, fileHeader.Filename) + // Get document type documentType := r.FormValue(typeKey) if documentType == "" { @@ -219,6 +296,9 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) { continue } + // Log the document type for debugging + log.Printf("Document type for %s: '%s'", documentName, documentType) + documents = append(documents, DocumentData{ File: file, Header: fileHeader, @@ -275,26 +355,33 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) { resultHTML.WriteString("
") resultHTML.WriteString("

Upload Results

") - for jobID, jobResults := range results { - resultHTML.WriteString(fmt.Sprintf("
Job #%s
    ", jobID)) - - for _, result := range jobResults { - filename := result["filename"].(string) - success := result["success"].(bool) - - if success { - resultHTML.WriteString(fmt.Sprintf("
  • %s: Uploaded successfully
  • ", filename)) - } else { - errorMsg := result["error"].(string) - resultHTML.WriteString(fmt.Sprintf("
  • %s: %s
  • ", filename, errorMsg)) + if len(results) == 0 { + resultHTML.WriteString("

    No documents were uploaded. Please check that you have selected files and document types.

    ") + } else { + for jobID, jobResults := range results { + resultHTML.WriteString(fmt.Sprintf("
    Job #%s
      ", jobID)) + + for _, result := range jobResults { + filename := result["filename"].(string) + success := result["success"].(bool) + + if success { + resultHTML.WriteString(fmt.Sprintf("
    • %s: Uploaded successfully
    • ", filename)) + } else { + errorMsg := result["error"].(string) + resultHTML.WriteString(fmt.Sprintf("
    • %s: %s
    • ", filename, errorMsg)) + } } - } - resultHTML.WriteString("
    ") + resultHTML.WriteString("
") + } } resultHTML.WriteString("
") + // Add JavaScript to scroll to results + resultHTML.WriteString("") + w.Header().Set("Content-Type", "text/html") w.Write(resultHTML.Bytes()) } @@ -323,16 +410,10 @@ func DocumentFieldAddHandler(w http.ResponseWriter, r *http.Request) { diff --git a/templates/partials/csv_upload.html b/templates/partials/csv_upload.html index 69283c8..dfcf466 100644 --- a/templates/partials/csv_upload.html +++ b/templates/partials/csv_upload.html @@ -5,7 +5,7 @@ - diff --git a/templates/partials/document_upload.html b/templates/partials/document_upload.html index 87a0694..478759c 100644 --- a/templates/partials/document_upload.html +++ b/templates/partials/document_upload.html @@ -1,6 +1,35 @@ {{define "document_upload"}}

Document Uploads

-{{template "csv_upload"}} -{{template "document_upload_form"}} -{{template "upload_actions"}} + +
+ + +
+ +
+ +
+ + {{template "csv_upload" .}} + + + {{template "document_upload_form" .}} + + +
+ +
+ + +
+ Uploading... +
+
+ +
+
+
+
+
{{end}} \ No newline at end of file diff --git a/templates/partials/document_upload_form.html b/templates/partials/document_upload_form.html index eba8950..15107c8 100644 --- a/templates/partials/document_upload_form.html +++ b/templates/partials/document_upload_form.html @@ -19,16 +19,10 @@ diff --git a/templates/partials/upload_actions.html b/templates/partials/upload_actions.html index b837ebb..acdbe94 100644 --- a/templates/partials/upload_actions.html +++ b/templates/partials/upload_actions.html @@ -3,9 +3,10 @@
+