diff --git a/apps/web/main.go b/apps/web/main.go index 8b5dd4f..4ccadab 100644 --- a/apps/web/main.go +++ b/apps/web/main.go @@ -65,6 +65,13 @@ func main() { 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") + protected.HandleFunc("/documents/remove/process-csv", web.ProcessRemoveCSVHandler).Methods("POST") + protected.HandleFunc("/documents/remove/job-selection", web.JobSelectionHandler).Methods("POST") + protected.HandleFunc("/documents/remove/job/{jobID}", web.GetJobAttachmentsHandler).Methods("GET") + protected.HandleFunc("/documents/remove/job/{jobID}", web.RemoveJobAttachmentsHandler).Methods("POST") + log.Println("Server starting on :8080") log.Fatal(http.ListenAndServe(":8080", r)) } diff --git a/internal/api/attachments.go b/internal/api/attachments.go index 6429849..4489e78 100644 --- a/internal/api/attachments.go +++ b/internal/api/attachments.go @@ -237,3 +237,40 @@ func (s *Session) DeleteAttachment(attachmentID string) error { return nil } + +// GetJobAttachments retrieves all attachments for a given job ID +func (s *Session) GetJobAttachments(jobID string) ([]map[string]interface{}, error) { + url := fmt.Sprintf("%s/job/%s/paperwork", BaseURL, jobID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("error creating request: %v", err) + } + + // Add authorization header + req.Header.Set("Cookie", s.Cookie) + + // Send the request + resp, err := s.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("error sending request: %v", err) + } + defer resp.Body.Close() + + // Check for error response + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API returned error: %d %s - %s", resp.StatusCode, resp.Status, string(body)) + } + + // Parse the response + var result struct { + Attachments []map[string]interface{} `json:"objects"` + } + + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + return result.Attachments, nil +} diff --git a/internal/handlers/web/document_remove.go b/internal/handlers/web/document_remove.go new file mode 100644 index 0000000..95640cc --- /dev/null +++ b/internal/handlers/web/document_remove.go @@ -0,0 +1,408 @@ +package web + +import ( + "bytes" + "encoding/csv" + "fmt" + "log" + "net/http" + "strconv" + "strings" + "sync" + "time" + + root "marmic/servicetrade-toolbox" + "marmic/servicetrade-toolbox/internal/api" + + "github.com/gorilla/mux" +) + +// DocumentRemoveHandler handles the document removal page +func DocumentRemoveHandler(w http.ResponseWriter, r *http.Request) { + session, ok := r.Context().Value("session").(*api.Session) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + tmpl := root.WebTemplates + data := map[string]interface{}{ + "Title": "Document Removal", + "Session": session, + } + + if r.Header.Get("HX-Request") == "true" { + // For HTMX requests, just send the document_remove partial + if err := tmpl.ExecuteTemplate(w, "document_remove", data); err != nil { + log.Printf("Template execution error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } else { + // For full page requests, first render document_remove into a buffer + var contentBuf bytes.Buffer + if err := tmpl.ExecuteTemplate(&contentBuf, "document_remove", data); err != nil { + log.Printf("Template execution error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + + // Add the rendered content to the data for the layout + data["BodyContent"] = contentBuf.String() + + // Now render the layout with our content + if err := tmpl.ExecuteTemplate(w, "layout.html", data); err != nil { + log.Printf("Template execution error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + } +} + +// ProcessRemoveCSVHandler processes a CSV file containing job IDs for document removal +func ProcessRemoveCSVHandler(w http.ResponseWriter, r *http.Request) { + // We don't use the session in the body but check it for auth + _, ok := r.Context().Value("session").(*api.Session) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Check if the request method is POST + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Parse the multipart form data with a 10MB limit + if err := r.ParseMultipartForm(10 << 20); err != nil { + http.Error(w, "Unable to parse form: "+err.Error(), http.StatusBadRequest) + return + } + + // Get the file from the form + file, _, err := r.FormFile("csv") + if err != nil { + http.Error(w, "Error retrieving file: "+err.Error(), http.StatusBadRequest) + return + } + defer file.Close() + + // Read the CSV data + csvData, err := csv.NewReader(file).ReadAll() + if err != nil { + http.Error(w, "Error reading CSV file: "+err.Error(), http.StatusBadRequest) + return + } + + // Extract job IDs from CSV - first column only for simplicity + var jobIDs []string + for rowIndex, row := range csvData { + // Skip header row if present + if rowIndex == 0 && len(csvData) > 1 { + // Check if first row looks like a header (non-numeric content) + _, err := strconv.Atoi(strings.TrimSpace(row[0])) + if err != nil { + continue // Skip this row as it's likely a header + } + } + + if len(row) > 0 && row[0] != "" { + jobID := strings.TrimSpace(row[0]) + if jobID != "" { + jobIDs = append(jobIDs, jobID) + } + } + } + + totalJobs := len(jobIDs) + log.Printf("Extracted %d job IDs from CSV", totalJobs) + + if totalJobs == 0 { + http.Error(w, "No valid job IDs found in the CSV file", http.StatusBadRequest) + return + } + + // Create a hidden input with the job IDs + jobsValue := strings.Join(jobIDs, ",") + jobSampleDisplay := getJobSampleDisplay(jobIDs) + + // Generate HTML for the main response (hidden input for job-ids-removal-container) + var responseHTML bytes.Buffer + responseHTML.WriteString(fmt.Sprintf(``, jobsValue)) + responseHTML.WriteString(fmt.Sprintf(`

Found %d job(s) in the CSV file

`, totalJobs)) + responseHTML.WriteString(fmt.Sprintf(`

Sample job IDs: %s

`, jobSampleDisplay)) + + // Generate out-of-band swap for the preview section + responseHTML.WriteString(fmt.Sprintf(` +
+

Detected Jobs

+
+
+

Sample job IDs: %s

+
+
+
+ `, jobSampleDisplay)) + + // Send the response with the hidden input and preview + w.Header().Set("Content-Type", "text/html") + w.Write(responseHTML.Bytes()) +} + +// After the CSV is processed, a separate request should load job attachments +// This handler is for Step 2 +func JobSelectionHandler(w http.ResponseWriter, r *http.Request) { + // We don't use the session directly but check it for auth + _, ok := r.Context().Value("session").(*api.Session) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + jobIDs := r.FormValue("jobIDs") + if jobIDs == "" { + http.Error(w, "No job IDs provided", http.StatusBadRequest) + return + } + + jobs := strings.Split(jobIDs, ",") + if len(jobs) == 0 { + http.Error(w, "No valid job IDs found", http.StatusBadRequest) + return + } + + var resultHTML bytes.Buffer + resultHTML.WriteString("
") + resultHTML.WriteString("

Jobs from CSV

") + resultHTML.WriteString("

Click a job to view and manage its documents.

") + resultHTML.WriteString("
") + + for _, jobID := range jobs { + resultHTML.WriteString(fmt.Sprintf(` + + `, jobID, jobID, jobID, jobID, jobID)) + } + + resultHTML.WriteString("
") // End of job-list + resultHTML.WriteString("
") // End of job-list-container + + w.Header().Set("Content-Type", "text/html") + w.Write(resultHTML.Bytes()) +} + +// GetJobAttachmentsHandler retrieves attachments for a specific job +func GetJobAttachmentsHandler(w http.ResponseWriter, r *http.Request) { + session, ok := r.Context().Value("session").(*api.Session) + if !ok { + vars := mux.Vars(r) + jobID := vars["jobID"] + renderErrorTemplate(w, "job_attachments", "You must be logged in to use this feature", jobID) + return + } + + vars := mux.Vars(r) + jobID := vars["jobID"] + if jobID == "" { + renderErrorTemplate(w, "job_attachments", "Job ID is required", jobID) + return + } + + // Get attachments for the job + attachments, err := session.GetJobAttachments(jobID) + if err != nil { + renderErrorTemplate(w, "job_attachments", fmt.Sprintf("Failed to get attachments: %v", err), jobID) + return + } + + tmpl := root.WebTemplates + data := map[string]interface{}{ + "JobID": jobID, + "Attachments": attachments, + "Session": session, + } + + if err := tmpl.ExecuteTemplate(w, "job_attachments", data); err != nil { + log.Printf("Template execution error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } +} + +// RemoveJobAttachmentsHandler handles the removal of attachments from a job +func RemoveJobAttachmentsHandler(w http.ResponseWriter, r *http.Request) { + session, ok := r.Context().Value("session").(*api.Session) + if !ok { + vars := mux.Vars(r) + jobID := vars["jobID"] + renderErrorTemplate(w, "job_attachments", "You must be logged in to use this feature", jobID) + return + } + + vars := mux.Vars(r) + jobID := vars["jobID"] + if jobID == "" { + renderErrorTemplate(w, "job_attachments", "Job ID is required", jobID) + return + } + + // Parse the form + if err := r.ParseForm(); err != nil { + renderErrorTemplate(w, "job_attachments", fmt.Sprintf("Error parsing form: %v", err), jobID) + return + } + + // Get the attachment IDs to remove + attachmentIDs := r.PostForm["attachment_ids"] + if len(attachmentIDs) == 0 { + renderErrorTemplate(w, "job_attachments", "No attachments selected for deletion", jobID) + return + } + + // Process deletion with rate limiting (max 5 concurrent requests) + results := struct { + Success bool + SuccessCount int + ErrorCount int + Files []struct { + Name string + Success bool + Error string + } + }{ + Success: true, + Files: make([]struct { + Name string + Success bool + Error string + }, 0, len(attachmentIDs)), + } + + // Set up rate limiting + semaphore := make(chan struct{}, 5) // Allow 5 concurrent requests + var wg sync.WaitGroup + var mu sync.Mutex // Mutex for updating results + + for _, attachmentID := range attachmentIDs { + wg.Add(1) + semaphore <- struct{}{} // Acquire semaphore + + go func(id string) { + defer wg.Done() + defer func() { <-semaphore }() // Release semaphore + + // Get attachment info first to get the name + attachmentInfo, err := session.GetAttachmentInfo(id) + + fileResult := struct { + Name string + Success bool + Error string + }{ + Name: fmt.Sprintf("Attachment ID: %s", id), + } + + if err == nil { + // Get description if available + if description, ok := attachmentInfo["description"].(string); ok { + fileResult.Name = description + } + } + + // Delete the attachment + err = session.DeleteAttachment(id) + + mu.Lock() + defer mu.Unlock() + + if err != nil { + fileResult.Success = false + fileResult.Error = err.Error() + results.ErrorCount++ + results.Success = false + } else { + fileResult.Success = true + results.SuccessCount++ + } + + results.Files = append(results.Files, fileResult) + + // Add a slight delay to avoid overwhelming the API + time.Sleep(100 * time.Millisecond) + }(attachmentID) + } + + wg.Wait() // Wait for all deletions to complete + + tmpl := root.WebTemplates + data := map[string]interface{}{ + "JobID": jobID, + "Session": session, + "SuccessCount": results.SuccessCount, + "ErrorCount": results.ErrorCount, + "JobsProcessed": 1, + "Results": []map[string]interface{}{ + { + "JobID": jobID, + "Success": results.Success, + "Files": results.Files, + }, + }, + } + + // Generate HTML for results that will go to the removal_results div + if err := tmpl.ExecuteTemplate(w, "removal_results", data); err != nil { + log.Printf("Template execution error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } +} + +// JobListHandler renders the job list for document removal +func JobListHandler(w http.ResponseWriter, r *http.Request) { + session, ok := r.Context().Value("session").(*api.Session) + if !ok { + renderErrorTemplate(w, "job_list", "You must be logged in to use this feature") + return + } + + if err := r.ParseForm(); err != nil { + renderErrorTemplate(w, "job_list", fmt.Sprintf("Error parsing form: %v", err)) + return + } + + tmpl := root.WebTemplates + data := map[string]interface{}{ + "JobIDs": r.PostForm["job_ids"], + "Session": session, + } + + if err := tmpl.ExecuteTemplate(w, "job_list", data); err != nil { + log.Printf("Template execution error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } +} + +// Helper function to render error templates +func renderErrorTemplate(w http.ResponseWriter, templateName, errorMsg string, jobID ...string) { + tmpl := root.WebTemplates + data := map[string]interface{}{ + "Error": errorMsg, + } + + if len(jobID) > 0 && jobID[0] != "" { + data["JobID"] = jobID[0] + } + + if err := tmpl.ExecuteTemplate(w, templateName, data); err != nil { + log.Printf("Template execution error: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } +} diff --git a/internal/handlers/web/documents.go b/internal/handlers/web/documents.go index 6cfb7c6..3f30469 100644 --- a/internal/handlers/web/documents.go +++ b/internal/handlers/web/documents.go @@ -147,32 +147,29 @@ func ProcessCSVHandler(w http.ResponseWriter, r *http.Request) { // Create a hidden input with the job IDs jobsValue := strings.Join(jobNumbers, ",") - - // Generate HTML for job preview - don't show all IDs for large datasets - var jobPreviewHTML string - if totalJobs > 0 { - jobPreviewHTML = fmt.Sprintf(` - - - - -
+ jobSampleDisplay := getJobSampleDisplay(jobNumbers) + + // Generate HTML for the main response (hidden input for job-ids-container) + var responseHTML bytes.Buffer + responseHTML.WriteString(fmt.Sprintf(``, jobsValue)) + responseHTML.WriteString(fmt.Sprintf(`

Found %d job(s) in the CSV file

`, totalJobs)) + responseHTML.WriteString(fmt.Sprintf(`

Sample job IDs: %s

`, jobSampleDisplay)) + + // Generate out-of-band swap for the preview section + responseHTML.WriteString(fmt.Sprintf(` +
+

Detected Jobs

+

Found %d job(s) in the CSV file

Sample job IDs: %s

- `, jobsValue, totalJobs, getJobSampleDisplay(jobNumbers)) - } else { - jobPreviewHTML = ` -

No valid job numbers found in the CSV file.

- ` - } +
+ `, totalJobs, jobSampleDisplay)) w.Header().Set("Content-Type", "text/html") - w.Write([]byte(jobPreviewHTML)) + w.Write(responseHTML.Bytes()) } // Helper function to show sample job IDs with a limit diff --git a/static/css/upload.css b/static/css/upload.css index cf1e9aa..0fa12f4 100644 --- a/static/css/upload.css +++ b/static/css/upload.css @@ -227,7 +227,8 @@ } /* CSV Preview styles */ -#csv-preview { +#csv-preview, +#csv-preview-removal { margin-top: 1rem; padding: 1rem; background-color: var(--content-bg); @@ -243,7 +244,8 @@ box-shadow: var(--dashboard-shadow); } -#csv-preview p { +#csv-preview p, +#csv-preview-removal p { margin: 0.5rem 0; color: var(--content-text); } diff --git a/templates/dashboard.html b/templates/dashboard.html index f805aa7..0eac151 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -10,8 +10,7 @@ {{template "document_upload" .}}
-

Manage Companies

- View Companies + {{template "document_remove" .}}
diff --git a/templates/partials/document_remove.html b/templates/partials/document_remove.html new file mode 100644 index 0000000..64f6040 --- /dev/null +++ b/templates/partials/document_remove.html @@ -0,0 +1,34 @@ +{{define "document_remove"}} +

Document Removal

+ +
+ +
+ + {{template "document_remove_csv" .}} +
+ + +
+ + {{template "document_remove_form" .}} + + + + +
+ +
+
+ + +
+ +
+ +
+
+
+{{end}} \ No newline at end of file diff --git a/templates/partials/document_remove_csv.html b/templates/partials/document_remove_csv.html new file mode 100644 index 0000000..5a5ceed --- /dev/null +++ b/templates/partials/document_remove_csv.html @@ -0,0 +1,25 @@ +{{define "document_remove_csv"}} +
+ + + + + + +
+ + +{{end}} \ No newline at end of file diff --git a/templates/partials/document_remove_form.html b/templates/partials/document_remove_form.html new file mode 100644 index 0000000..41c8762 --- /dev/null +++ b/templates/partials/document_remove_form.html @@ -0,0 +1,28 @@ +{{define "document_remove_form"}} +
+
+ + + + + +
+ +
+ +

After uploading a CSV and clicking "Load Jobs", you'll see jobs here.

+
+ +
+ +
+
+{{end}} \ No newline at end of file diff --git a/templates/partials/document_upload.html b/templates/partials/document_upload.html index 478759c..19ab302 100644 --- a/templates/partials/document_upload.html +++ b/templates/partials/document_upload.html @@ -5,7 +5,7 @@ hx-indicator="#upload-loading-indicator"> -
+ diff --git a/templates/partials/job_attachments.html b/templates/partials/job_attachments.html new file mode 100644 index 0000000..33e775a --- /dev/null +++ b/templates/partials/job_attachments.html @@ -0,0 +1,54 @@ +{{define "job_attachments"}} +
+

Job #{{.JobID}}

+ + {{if .Error}} +
Error: {{.Error}}
+ {{else}} + {{if len .Attachments}} +
+
+ {{range .Attachments}} +
+ +
+ {{end}} +
+ + + +
+
+ Deleting documents... +
+
+ {{else}} +
No attachments found for this job.
+ {{end}} + {{end}} +
+{{end}} + +{{define "document_type"}} +{{if eq . 0}}Job Paperwork +{{else if eq . 1}}Job Vendor Bill +{{else if eq . 2}}Job Picture +{{else if eq . 3}}Deficiency Repair Proposal +{{else if eq . 4}}Generic Attachment +{{else if eq . 5}}Avatar Image +{{else if eq . 6}}Import +{{else if eq . 7}}Blank Paperwork +{{else if eq . 8}}Work Acknowledgement +{{else if eq . 9}}Logo +{{else if eq . 10}}Job Invoice +{{else}}Unknown ({{.}}) +{{end}} +{{end}} \ No newline at end of file diff --git a/templates/partials/job_list.html b/templates/partials/job_list.html new file mode 100644 index 0000000..64ab16b --- /dev/null +++ b/templates/partials/job_list.html @@ -0,0 +1,27 @@ +{{define "job_list"}} +
+

Jobs from CSV

+ + {{if .Error}} +
Error: {{.Error}}
+ {{else}} + {{if .JobIDs}} +

Found {{len .JobIDs}} job(s) in the CSV file. Click a job to view and manage its documents.

+ +
+ {{range .JobIDs}} + + {{end}} +
+ {{else}} +
No jobs found. Please upload a CSV file with job IDs.
+ {{end}} + {{end}} +
+{{end}} \ No newline at end of file diff --git a/templates/partials/removal_results.html b/templates/partials/removal_results.html new file mode 100644 index 0000000..e43ab1f --- /dev/null +++ b/templates/partials/removal_results.html @@ -0,0 +1,51 @@ +{{define "removal_results"}} +
+

Document Removal Results

+ + {{if .Error}} +
Error: {{.Error}}
+ {{else}} +
+

Successfully removed {{.SuccessCount}} document(s).

+ {{if gt .ErrorCount 0}} +

Failed to remove {{.ErrorCount}} document(s).

+ {{end}} + {{if gt .JobsProcessed 0}} +

Processed {{.JobsProcessed}} job(s).

+ {{end}} +
+ + {{if .Results}} +
+ {{range $job := .Results}} +
+

Job #{{$job.JobID}}

+ + {{if $job.Success}} +
Successfully processed
+ {{else}} +
Error: {{$job.Error}}
+ {{end}} + + {{if $job.Files}} +
+ {{range $file := $job.Files}} +
+ {{$file.Name}} + {{if $file.Success}} + + {{else}} + + {{$file.Error}} + {{end}} +
+ {{end}} +
+ {{end}} +
+ {{end}} +
+ {{end}} + {{end}} +
+{{end}} \ No newline at end of file