Browse Source

feat: added multi-upload field for files instead of continually adding single new fields

document-upload-removal-layout-update
nic 11 months ago
parent
commit
51093cbd07
  1. 2
      apps/web/main.go
  2. 264
      internal/handlers/web/documents.go
  3. 34
      templates/partials/document_upload.html

2
apps/web/main.go

@ -63,8 +63,6 @@ func main() {
protected.HandleFunc("/documents", web.DocumentsHandler).Methods("GET")
protected.HandleFunc("/process-csv", web.ProcessCSVHandler).Methods("POST")
protected.HandleFunc("/upload-documents", web.UploadDocumentsHandler).Methods("POST")
protected.HandleFunc("/document-field-add", web.DocumentFieldAddHandler).Methods("GET")
protected.HandleFunc("/document-field-remove", web.DocumentFieldRemoveHandler).Methods("GET")
// Document removal routes
protected.HandleFunc("/documents/remove", web.DocumentRemoveHandler).Methods("GET")

264
internal/handlers/web/documents.go

@ -190,17 +190,15 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Unable to parse form: "+err.Error(), http.StatusBadRequest)
return
}
defer r.MultipartForm.RemoveAll() // Clean up temporary files
// Get job numbers from form values
jobNumbers := r.FormValue("jobNumbers")
if jobNumbers == "" {
jobNumbers = r.FormValue("job-ids")
if jobNumbers == "" {
log.Printf("No job numbers found in form values")
log.Printf("No job numbers found in hidden 'jobNumbers' input.")
http.Error(w, "No job numbers provided", http.StatusBadRequest)
return
}
}
log.Printf("Job numbers: %s", jobNumbers)
jobs := strings.Split(jobNumbers, ",")
@ -209,67 +207,51 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
return
}
// Get the single document type
docType := r.FormValue("documentType")
if docType == "" {
log.Printf("No document type selected")
http.Error(w, "Please select a document type", http.StatusBadRequest)
return
}
log.Printf("Document Type selected: %s", docType)
// Get the uploaded files from the 'documentFiles' input
fileHeaders := r.MultipartForm.File["documentFiles"]
if len(fileHeaders) == 0 {
http.Error(w, "No documents selected for upload", http.StatusBadRequest)
return
}
// Store file metadata
type FileMetadata struct {
FormField string
FileName string
Type string
CustomName string
TempFile string // Path to temp file if we create one
FileData []byte // File data if small enough to keep in memory
File *os.File // Open file handle if we're using a temp file
TempFile string // Path to temp file
File *os.File // Open file handle for the temp file
}
var filesToUpload []FileMetadata
// Process each file upload in the form
for formField, fileHeaders := range r.MultipartForm.File {
if !strings.HasPrefix(formField, "document-file-") {
continue
}
if len(fileHeaders) == 0 {
continue
}
fileHeader := fileHeaders[0]
// Process each uploaded file
for _, fileHeader := range fileHeaders {
if fileHeader.Filename == "" {
log.Printf("Skipping file header with empty filename.")
continue
}
// Get suffix for related form fields
suffix := strings.TrimPrefix(formField, "document-file-")
typeField := "document-type-" + suffix
nameField := "document-name-" + suffix
// Get document type and custom name
docType := r.FormValue(typeField)
if docType == "" {
log.Printf("No document type for file %s, skipping", fileHeader.Filename)
continue
}
customName := r.FormValue(nameField)
if customName != "" && !strings.Contains(customName, ".") {
ext := filepath.Ext(fileHeader.Filename)
if ext != "" {
customName = customName + ext
}
}
// Open the uploaded file
uploadedFile, err := fileHeader.Open()
if err != nil {
log.Printf("Error opening uploaded file %s: %v", fileHeader.Filename, err)
continue
// Optionally: decide if one error should halt all uploads or just skip this file
continue // Skip this file
}
// Prepare metadata
metadata := FileMetadata{
FormField: formField,
FileName: fileHeader.Filename,
Type: docType,
CustomName: customName,
Type: docType, // Use the single document type for all files
}
// Create a temp file for the upload (regardless of size to ensure streaming)
@ -277,32 +259,55 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
if err != nil {
log.Printf("Error creating temp file for %s: %v", fileHeader.Filename, err)
uploadedFile.Close()
continue
continue // Skip this file
}
// Copy the file content to the temp file
bytesCopied, err := io.Copy(tempFile, uploadedFile)
uploadedFile.Close()
uploadedFile.Close() // Close the original multipart file handle
if err != nil {
log.Printf("Error copying to temp file for %s: %v", fileHeader.Filename, err)
tempFile.Close()
os.Remove(tempFile.Name())
continue
tempFile.Close() // Close the temp file handle
os.Remove(tempFile.Name()) // Remove the partially written temp file
continue // Skip this file
}
log.Printf("Copied %d bytes of %s to temporary file: %s",
bytesCopied, fileHeader.Filename, tempFile.Name())
// Seek back to beginning for later reading
tempFile.Seek(0, 0)
// Seek back to beginning for later reading by upload goroutines
if _, err := tempFile.Seek(0, 0); err != nil {
log.Printf("Error seeking temp file for %s: %v", fileHeader.Filename, err)
tempFile.Close()
os.Remove(tempFile.Name())
continue // Skip this file
}
metadata.TempFile = tempFile.Name()
metadata.File = tempFile // Store the open file handle
metadata.File = tempFile // Store the open temp file handle
filesToUpload = append(filesToUpload, metadata)
}
// Ensure temp files associated with metadata are closed and removed later
defer func() {
log.Println("Running deferred cleanup for temp files...")
for _, fm := range filesToUpload {
if fm.File != nil {
fm.File.Close()
if fm.TempFile != "" {
err := os.Remove(fm.TempFile)
if err != nil && !os.IsNotExist(err) { // Don't log error if file already gone
log.Printf("Error cleaning up temp file %s: %v", fm.TempFile, err)
} else if err == nil {
log.Printf("Cleaned up temp file: %s", fm.TempFile)
}
}
}
}
}()
if len(filesToUpload) == 0 {
http.Error(w, "No valid documents selected for upload", http.StatusBadRequest)
http.Error(w, "No valid documents could be processed for upload", http.StatusBadRequest)
return
}
@ -333,15 +338,19 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
semaphore := make(chan struct{}, maxConcurrent)
// Start the upload workers
log.Printf("Starting %d upload workers for %d total uploads",
maxConcurrent, totalUploads)
log.Printf("Starting %d upload workers for %d total uploads (%d jobs x %d files)",
maxConcurrent, totalUploads, len(jobs), len(filesToUpload))
for _, jobID := range jobs {
for _, metadata := range filesToUpload {
// Create a closure capture of the metadata for the goroutine
// This is crucial because the 'metadata' variable in the loop will change
currentMetadata := metadata
wg.Add(1)
// Launch a goroutine for each job+document combination
go func(jobID string, metadata FileMetadata) {
go func(jobID string, meta FileMetadata) {
defer wg.Done()
// Acquire a semaphore slot
@ -351,16 +360,14 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
// Add a small delay to avoid overwhelming the API
time.Sleep(requestDelay)
// Get the file name to use (custom name or original)
fileName := metadata.FileName
if metadata.CustomName != "" {
fileName = metadata.CustomName
}
// Get the file name to use (original filename)
fileName := meta.FileName
// Create a fresh file reader for each upload to avoid sharing file handles
fileHandle, err := os.Open(metadata.TempFile)
// Use a fresh reader for the temp file for each upload goroutine
// Re-open the temp file for reading to avoid race conditions on the file pointer
fileHandle, err := os.Open(meta.TempFile)
if err != nil {
log.Printf("Error opening temp file for %s: %v", fileName, err)
log.Printf("Error re-opening temp file %s for job %s: %v", meta.TempFile, jobID, err)
resultsChan <- UploadResult{
JobID: jobID,
DocName: fileName,
@ -373,10 +380,13 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
defer fileHandle.Close() // Close this handle when done with this upload
// Get the expected file size for validation
fileInfo, statErr := os.Stat(metadata.TempFile)
fileInfo, statErr := fileHandle.Stat() // Stat the newly opened handle
var expectedSize int64
if statErr == nil {
expectedSize = fileInfo.Size()
} else {
log.Printf("Error getting file info for %s (job %s): %v", fileName, jobID, statErr)
// Continue without size check if stat fails, but log it
}
// Add jitter delay for large batch uploads (more than 10 jobs)
@ -387,14 +397,12 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
// Wrap with size tracker
sizeTracker := &readCloserWithSize{reader: fileHandle, size: 0}
fileReader := sizeTracker
// Log streaming progress
log.Printf("Starting to stream file %s to job %s from fresh file handle", fileName, jobID)
log.Printf("Starting to stream file %s to job %s from temp file %s", fileName, jobID, meta.TempFile)
// Call ServiceTrade API with the file reader
uploadStart := time.Now()
result, err := session.UploadAttachmentFile(jobID, fileName, metadata.Type, fileReader)
result, err := session.UploadAttachmentFile(jobID, fileName, meta.Type, sizeTracker)
uploadDuration := time.Since(uploadStart)
// Get the actual size that was uploaded
@ -402,7 +410,7 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
// Verify the upload size matches the expected file size
sizeMatch := true
if expectedSize > 0 && math.Abs(float64(expectedSize-fileSize)) > float64(expectedSize)*0.05 {
if expectedSize > 0 && math.Abs(float64(expectedSize-fileSize)) > float64(expectedSize)*0.05 { // Allow 5% tolerance
sizeMatch = false
log.Printf("WARNING: Size mismatch for %s to job %s. Expected: %d, Uploaded: %d",
fileName, jobID, expectedSize, fileSize)
@ -440,27 +448,17 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
FileSize: fileSize,
}
}
}(jobID, metadata)
}(jobID, currentMetadata) // Pass the captured metadata
}
}
// Clean up temp files when all uploads are done
defer func() {
for _, metadata := range filesToUpload {
if metadata.File != nil {
metadata.File.Close()
if metadata.TempFile != "" {
os.Remove(metadata.TempFile)
log.Printf("Cleaned up temp file: %s", metadata.TempFile)
}
}
}
}()
// NOTE: The deferred cleanup function for temp files defined earlier will run after this point.
// Close the results channel when all uploads are done
go func() {
wg.Wait()
close(resultsChan)
log.Println("All upload goroutines finished.")
}()
// Collect results
@ -527,15 +525,18 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
// File count stat
resultHTML.WriteString("<div class=\"stat-box\">")
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-value\">%d</div>", totalSuccess+totalFailure))
// Use resultsCount which reflects total files attempted
resultHTML.WriteString(fmt.Sprintf("<div class=\"stat-value\">%d</div>", resultsCount))
resultHTML.WriteString("<div class=\"stat-label\">Files Processed</div>")
resultHTML.WriteString("</div>")
resultHTML.WriteString("</div>") // End of upload-stats
// Add completion message
if totalFailure == 0 {
if totalFailure == 0 && resultsCount > 0 {
resultHTML.WriteString("<p>All documents were successfully uploaded to ServiceTrade!</p>")
} else if resultsCount == 0 {
resultHTML.WriteString("<p>No documents were processed for upload.</p>")
} else {
resultHTML.WriteString("<p>Some documents failed to upload. See details below.</p>")
}
@ -555,28 +556,37 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
for _, jobID := range sortedJobs {
jobResults := results[jobID]
// Determine job success status
jobSuccess := true
// Determine job success status based on results for *this job*
jobHasSuccess := false
jobHasFailure := false
for _, result := range jobResults {
if !result.Success {
jobSuccess = false
break
if result.Success {
jobHasSuccess = true
} else {
jobHasFailure = true
}
}
// Job result row
jobClass := "success"
if !jobSuccess {
jobClass = "error"
// Job result row styling
jobClass := "neutral" // Default if somehow no results for a job ID
if jobHasSuccess && !jobHasFailure {
jobClass = "success"
} else if jobHasFailure {
jobClass = "error" // Prioritize showing error if any file failed for this job
}
resultHTML.WriteString(fmt.Sprintf("<div class=\"job-result %s\">", jobClass))
resultHTML.WriteString(fmt.Sprintf("<span class=\"job-id\">Job ID: %s</span>", jobID))
resultHTML.WriteString(fmt.Sprintf("<div class=\"job-id\">Job ID: %s</div>", jobID)) // Wrap ID for better styling
// File results
if len(jobResults) > 0 {
resultHTML.WriteString("<div class=\"file-results\">")
// Sort file results by name for consistency
sort.Slice(jobResults, func(i, j int) bool {
return jobResults[i].DocName < jobResults[j].DocName
})
for _, result := range jobResults {
fileClass := "success"
icon := "✓"
@ -585,7 +595,9 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
if !result.Success {
fileClass = "error"
icon = "✗"
message = result.Error
// Sanitize error message slightly for HTML display if needed
message = strings.ReplaceAll(result.Error, "<", "&lt;")
message = strings.ReplaceAll(message, ">", "&gt;")
}
resultHTML.WriteString(fmt.Sprintf("<div class=\"file-result %s\">", fileClass))
@ -597,7 +609,7 @@ func UploadDocumentsHandler(w http.ResponseWriter, r *http.Request) {
resultHTML.WriteString("</div>") // End of file-results
} else {
resultHTML.WriteString("<p>No files processed for this job.</p>")
resultHTML.WriteString("<p>No file upload results for this job.</p>") // More specific message
}
resultHTML.WriteString("</div>") // End of job-result
@ -622,8 +634,11 @@ func (r *readCloserWithSize) Read(p []byte) (n int, err error) {
}
func (r *readCloserWithSize) Close() error {
if r.reader != nil {
return r.reader.Close()
}
return nil // Allow closing nil reader safely
}
// Size returns the current size of data read
func (r *readCloserWithSize) Size() int64 {
@ -631,58 +646,7 @@ func (r *readCloserWithSize) Size() int64 {
}
// DocumentFieldAddHandler generates a new document field for the form
func DocumentFieldAddHandler(w http.ResponseWriter, r *http.Request) {
// Generate a random ID for the new field
newId := fmt.Sprintf("%d", time.Now().UnixNano())
// Create HTML for a new document row
html := fmt.Sprintf(`
<div class="document-row" id="document-row-%s">
<div class="document-field">
<label>Select Document:</label>
<input class="card-input" type="file" id="document-file-%s" name="document-file-%s">
</div>
<div class="document-field-row">
<div class="document-field document-name-field">
<label>Document Name (optional):</label>
<input class="card-input" type="text" id="document-name-%s" name="document-name-%s"
placeholder="Document Name">
</div>
<div class="document-field document-type-field">
<label>Document Type:</label>
<select class="card-input" id="document-type-%s" name="document-type-%s">
<option value="">Select Document Type</option>
<option value="1" selected>Job Paperwork</option>
<option value="2">Job Vendor Bill</option>
<option value="7">Generic Attachment</option>
<option value="10">Blank Paperwork</option>
<option value="14">Job Invoice</option>
</select>
</div>
</div>
<button type="button" class="remove-document warning-button"
hx-get="/document-field-remove?id=%s"
hx-target="#document-row-%s"
hx-swap="outerHTML">Remove</button>
</div>
`, newId, newId, newId, newId, newId, newId, newId, newId, newId)
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(html))
}
// REMOVED as it's no longer needed with the multi-file input
// DocumentFieldRemoveHandler handles the removal of a document field
func DocumentFieldRemoveHandler(w http.ResponseWriter, r *http.Request) {
// We read the ID but don't need to use it for simple removal
_ = r.URL.Query().Get("id")
// Count how many document rows exist
// For simplicity, we'll just return an empty response to remove the field
// In a complete implementation, we'd check if this is the last field and handle that case
w.Header().Set("Content-Type", "text/html")
w.Write([]byte(""))
}
// REMOVED as it's no longer needed with the multi-file input

34
templates/partials/document_upload.html

@ -49,24 +49,18 @@
<!-- Step 2: Document Upload -->
<div id="step2" class="content" style="display: none;">
<h3 class="submenu-header">Step 2: Upload Documents</h3>
<div id="document-upload-container">
<div class="document-row" id="document-row-1">
<div class="document-field">
<label>Select Document:</label>
<input class="card-input" type="file" id="document-file-1" name="document-file-1">
</div>
<h3 class="submenu-header">Step 2: Select Documents</h3>
<div class="document-field-row">
<div class="document-field document-name-field">
<label>Document Name (optional):</label>
<input class="card-input" type="text" id="document-name-1" name="document-name-1"
placeholder="Document Name">
<!-- Single file input for multiple documents -->
<div class="document-field">
<label for="document-files">Select Document(s):</label>
<input class="card-input" type="file" id="document-files" name="documentFiles" multiple>
</div>
<!-- Single document type selector -->
<div class="document-field document-type-field">
<label>Document Type:</label>
<select class="card-input" id="document-type-1" name="document-type-1">
<label for="document-type">Document Type (applies to all selected documents):</label>
<select class="card-input" id="document-type" name="documentType">
<option value="">Select Document Type</option>
<option value="1" selected>Job Paperwork</option>
<option value="2">Job Vendor Bill</option>
@ -75,14 +69,9 @@
<option value="14">Job Invoice</option>
</select>
</div>
</div>
<button type="button" class="remove-document warning-button" hx-get="/document-field-remove?id=1"
hx-target="#document-row-1" hx-swap="outerHTML" style="display: none;">Remove</button>
</div>
</div>
<button type="button" id="add-document" class="caution-button" hx-get="/document-field-add"
hx-target="#document-upload-container" hx-swap="beforeend">Add Another Document</button>
<!-- Remove the Add Another Document button -->
<!-- Remove the dynamic document rows and their remove buttons -->
<button type="button" class="btn-primary"
onclick="document.getElementById('step3').style.display = 'block';">
@ -136,6 +125,9 @@
// Show step 1
document.getElementById('step1').style.display = 'block';
// Reset the file input (important for `multiple`)
document.getElementById('document-files').value = '';
}
// Add event listener for form submission

Loading…
Cancel
Save