diff --git a/apps/web/main.go b/apps/web/main.go index 4ccadab..a42210e 100644 --- a/apps/web/main.go +++ b/apps/web/main.go @@ -70,7 +70,8 @@ func main() { 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") + protected.HandleFunc("/documents/remove/attachments/{jobID}", web.RemoveJobAttachmentsHandler).Methods("POST") + protected.HandleFunc("/documents/remove/bulk", web.BulkRemoveDocumentsHandler).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 4489e78..1ed9b4f 100644 --- a/internal/api/attachments.go +++ b/internal/api/attachments.go @@ -240,6 +240,7 @@ func (s *Session) DeleteAttachment(attachmentID string) error { // GetJobAttachments retrieves all attachments for a given job ID func (s *Session) GetJobAttachments(jobID string) ([]map[string]interface{}, error) { + log.Printf("GetJobAttachments: Fetching attachments for job %s", jobID) url := fmt.Sprintf("%s/job/%s/paperwork", BaseURL, jobID) req, err := http.NewRequest("GET", url, nil) @@ -249,6 +250,7 @@ func (s *Session) GetJobAttachments(jobID string) ([]map[string]interface{}, err // Add authorization header req.Header.Set("Cookie", s.Cookie) + log.Printf("GetJobAttachments: Authorization cookie length: %d", len(s.Cookie)) // Send the request resp, err := s.Client.Do(req) @@ -257,19 +259,81 @@ func (s *Session) GetJobAttachments(jobID string) ([]map[string]interface{}, err } defer resp.Body.Close() - // Check for error response + // Read the full response body for complete error reporting + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %v", err) + } + + // Check for various error responses if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + // Check for authentication-related errors + if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden { + log.Printf("GetJobAttachments: Authentication error (status %d) for job %s", resp.StatusCode, jobID) + return nil, fmt.Errorf("authentication error: %s - %s", resp.Status, string(body)) + } + + // Check for job not found + if resp.StatusCode == http.StatusNotFound { + log.Printf("GetJobAttachments: Job %s not found (status 404)", jobID) + return nil, fmt.Errorf("job not found: %s", jobID) + } + + // Generic error + log.Printf("GetJobAttachments: API error (status %d) for job %s: %s", resp.StatusCode, jobID, string(body)) return nil, fmt.Errorf("API returned error: %d %s - %s", resp.StatusCode, resp.Status, string(body)) } - // Parse the response + // Parse the response - try multiple formats 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) + // Try to parse as standard format first + if err := json.Unmarshal(body, &result); err != nil { + // If standard parse fails, try parsing as raw map + log.Printf("GetJobAttachments: Standard JSON parsing failed, trying alternative formats for job %s: %v", jobID, err) + var mapResult map[string]interface{} + if err := json.Unmarshal(body, &mapResult); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + // Try to extract from various places in the map + attachments := make([]map[string]interface{}, 0) + + // Check for objects array + if objects, ok := mapResult["objects"].([]interface{}); ok { + log.Printf("GetJobAttachments: Found %d attachments in 'objects' field for job %s", len(objects), jobID) + for _, obj := range objects { + if attachment, ok := obj.(map[string]interface{}); ok { + attachments = append(attachments, attachment) + } + } + return attachments, nil + } + + // Check for data.attachments + if data, ok := mapResult["data"].(map[string]interface{}); ok { + if attList, ok := data["attachments"].([]interface{}); ok { + log.Printf("GetJobAttachments: Found %d attachments in 'data.attachments' field for job %s", len(attList), jobID) + for _, att := range attList { + if attachment, ok := att.(map[string]interface{}); ok { + attachments = append(attachments, attachment) + } + } + return attachments, nil + } + } + + // If nothing found, return empty with a warning + log.Printf("GetJobAttachments: No attachments found in response for job %s: %s", jobID, string(body)) + return attachments, nil + } + + if len(result.Attachments) == 0 { + log.Printf("GetJobAttachments: No attachments found for job %s in standard format", jobID) + } else { + log.Printf("GetJobAttachments: Found %d attachments for job %s in standard format", len(result.Attachments), jobID) } return result.Attachments, nil diff --git a/internal/api/jobs.go b/internal/api/jobs.go index 2c9627d..c487f7b 100644 --- a/internal/api/jobs.go +++ b/internal/api/jobs.go @@ -120,24 +120,257 @@ func (s *Session) GetJobDetails(jobID string) (map[string]interface{}, error) { // GetAttachmentsForJob retrieves attachments for a specific job func (s *Session) GetAttachmentsForJob(jobID string) (map[string]interface{}, error) { - resp, err := s.DoRequest("GET", fmt.Sprintf("/job/%s/paperwork", jobID), nil) + log.Printf("GetAttachmentsForJob: Fetching attachments for job %s", jobID) + + // Check if we have a valid cookie + if s.Cookie == "" { + log.Printf("GetAttachmentsForJob: No cookie found in session for job %s", jobID) + return nil, fmt.Errorf("no authentication cookie found") + } + + // Use the new API endpoint that returns attachments directly + url := fmt.Sprintf("%s/job/%s", BaseURL, jobID) + log.Printf("GetAttachmentsForJob: Using URL: %s", url) + + req, err := http.NewRequest("GET", url, nil) if err != nil { - return nil, err + log.Printf("GetAttachmentsForJob: Error creating request: %v", err) + return nil, fmt.Errorf("error creating request: %v", err) + } + + // Add authorization header + req.Header.Set("Cookie", s.Cookie) + log.Printf("GetAttachmentsForJob: Authorization cookie length: %d", len(s.Cookie)) + + // Set Accept header to get all data in one call + req.Header.Set("Accept", "application/json") + + // Add query parameter to include attachments + q := req.URL.Query() + q.Add("include", "attachments") + q.Add("include", "paperwork") + req.URL.RawQuery = q.Encode() + + log.Printf("GetAttachmentsForJob: Full URL with params: %s", req.URL.String()) + + // Debug headers + log.Printf("GetAttachmentsForJob: Request headers: %+v", req.Header) + + // Send the request + log.Printf("GetAttachmentsForJob: Sending request for job %s", jobID) + resp, err := s.Client.Do(req) + if err != nil { + log.Printf("GetAttachmentsForJob: Error sending request: %v", err) + return nil, fmt.Errorf("error sending request: %v", err) } defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - if resp.StatusCode != 200 { - return nil, fmt.Errorf("failed to get attachments: %s, response: %s", resp.Status, string(body)) + // Read the full response body for complete debugging + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("GetAttachmentsForJob: Error reading response body: %v", err) + return nil, fmt.Errorf("error reading response body: %v", err) + } + + log.Printf("GetAttachmentsForJob: Response status for job %s: %s", jobID, resp.Status) + + // Check for errors + if resp.StatusCode != http.StatusOK { + log.Printf("GetAttachmentsForJob: API error for job %s: %s - %s", jobID, resp.Status, string(body)) + return nil, fmt.Errorf("API error: %s - %s", resp.Status, string(body)) } + // Log a truncated version of the response for debugging + responsePreview := string(body) + if len(responsePreview) > 500 { + responsePreview = responsePreview[:500] + "... [truncated]" + } + log.Printf("GetAttachmentsForJob: Response preview for job %s: %s", jobID, responsePreview) + + // Parse the response var result map[string]interface{} if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("error unmarshalling response: %v", err) + log.Printf("GetAttachmentsForJob: Error parsing response: %v", err) + return nil, fmt.Errorf("error parsing response: %v", err) + } + + // Log the structure of the response + log.Printf("GetAttachmentsForJob: Root keys for job %s: %v", jobID, mapKeys(result)) + + // Check for data object structure + if data, ok := result["data"].(map[string]interface{}); ok { + log.Printf("GetAttachmentsForJob: Data keys for job %s: %v", jobID, mapKeys(data)) + + // Look for job information + if jobData, ok := data["job"].(map[string]interface{}); ok { + log.Printf("GetAttachmentsForJob: Job data keys: %v", mapKeys(jobData)) + } + + // First try to find attachments directly + if attachments, ok := data["attachments"]; ok { + switch att := attachments.(type) { + case []interface{}: + log.Printf("GetAttachmentsForJob: Found %d attachments in data.attachments array for job %s", len(att), jobID) + if len(att) > 0 { + log.Printf("GetAttachmentsForJob: First attachment type: %T", att[0]) + if attMap, ok := att[0].(map[string]interface{}); ok { + log.Printf("GetAttachmentsForJob: First attachment keys: %v", mapKeys(attMap)) + } + } + case map[string]interface{}: + log.Printf("GetAttachmentsForJob: Attachments is a map with keys: %v", mapKeys(att)) + + // Check for attachments in items or objects + for _, key := range []string{"items", "objects", "components", "records"} { + if items, ok := att[key].([]interface{}); ok { + log.Printf("GetAttachmentsForJob: Found %d items in attachments.%s for job %s", len(items), key, jobID) + if len(items) > 0 { + if itemMap, ok := items[0].(map[string]interface{}); ok { + log.Printf("GetAttachmentsForJob: First item in %s keys: %v", key, mapKeys(itemMap)) + } + } + } + } + default: + log.Printf("GetAttachmentsForJob: Attachments has unexpected type: %T", attachments) + } + } else { + log.Printf("GetAttachmentsForJob: No 'attachments' field in data for job %s", jobID) + + // Try to find paperwork directly + if paperwork, ok := data["paperwork"].([]interface{}); ok { + log.Printf("GetAttachmentsForJob: Found %d items in data.paperwork for job %s", len(paperwork), jobID) + if len(paperwork) > 0 { + if papMap, ok := paperwork[0].(map[string]interface{}); ok { + log.Printf("GetAttachmentsForJob: First paperwork keys: %v", mapKeys(papMap)) + } + } + } + + // Check for other potential containers + for _, key := range []string{"objects", "components", "items", "records"} { + if items, ok := data[key].([]interface{}); ok { + log.Printf("GetAttachmentsForJob: Found %d items in data.%s for job %s", len(items), key, jobID) + if len(items) > 0 { + if itemMap, ok := items[0].(map[string]interface{}); ok { + log.Printf("GetAttachmentsForJob: First item in %s keys: %v", key, mapKeys(itemMap)) + } + } + } + } + } + } else { + log.Printf("GetAttachmentsForJob: No 'data' field or it's not a map for job %s", jobID) + + // Try to find objects at root level + if objects, ok := result["objects"].([]interface{}); ok { + log.Printf("GetAttachmentsForJob: Found %d objects at root level for job %s", len(objects), jobID) + if len(objects) > 0 { + if objMap, ok := objects[0].(map[string]interface{}); ok { + log.Printf("GetAttachmentsForJob: First root object keys: %v", mapKeys(objMap)) + } + } + } + } + + // If response has an empty data structure, try a direct paperwork endpoint + if emptyAttachments := isEmptyResponse(result); emptyAttachments { + log.Printf("GetAttachmentsForJob: No attachments found in primary response, trying paperwork endpoint for job %s", jobID) + + paperworkURL := fmt.Sprintf("%s/job/%s/paperwork", BaseURL, jobID) + paperworkReq, err := http.NewRequest("GET", paperworkURL, nil) + if err == nil { + paperworkReq.Header.Set("Cookie", s.Cookie) + paperworkResp, err := s.Client.Do(paperworkReq) + + if err == nil && paperworkResp.StatusCode == http.StatusOK { + defer paperworkResp.Body.Close() + paperworkBody, err := io.ReadAll(paperworkResp.Body) + + if err == nil { + var paperworkResult map[string]interface{} + if err := json.Unmarshal(paperworkBody, &paperworkResult); err == nil { + log.Printf("GetAttachmentsForJob: Paperwork endpoint response keys: %v", mapKeys(paperworkResult)) + + // Merge the paperwork result with our original result + result["paperwork_data"] = paperworkResult + + // If the original result had a data field, add paperwork to it + if data, ok := result["data"].(map[string]interface{}); ok { + if objects, ok := paperworkResult["objects"].([]interface{}); ok && len(objects) > 0 { + log.Printf("GetAttachmentsForJob: Found %d objects in paperwork response", len(objects)) + data["paperwork_objects"] = objects + } + } + } + } + } + } } + return result, nil } +// Helper function to check if the response has no attachments +func isEmptyResponse(result map[string]interface{}) bool { + if data, ok := result["data"].(map[string]interface{}); ok { + // Check if attachments exist and have content + if attachments, ok := data["attachments"]; ok { + switch att := attachments.(type) { + case []interface{}: + return len(att) == 0 + case map[string]interface{}: + // Check items/objects inside the attachments map + hasItems := false + for _, key := range []string{"items", "objects", "components", "records"} { + if items, ok := att[key].([]interface{}); ok && len(items) > 0 { + hasItems = true + break + } + } + return !hasItems + default: + return true // Unknown type, consider empty + } + } + + // Check for paperwork + if paperwork, ok := data["paperwork"].([]interface{}); ok && len(paperwork) > 0 { + return false + } + + // Check for paperwork in job + if job, ok := data["job"].(map[string]interface{}); ok { + if paperwork, ok := job["paperwork"].([]interface{}); ok && len(paperwork) > 0 { + return false + } + } + + // Check other containers + for _, key := range []string{"objects", "components", "items", "records"} { + if items, ok := data[key].([]interface{}); ok && len(items) > 0 { + return false + } + } + } + + // Check root level objects + if objects, ok := result["objects"].([]interface{}); ok && len(objects) > 0 { + return false + } + + return true +} + +// Helper function to get map keys for logging +func mapKeys(m map[string]interface{}) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return keys +} + // GetDeficiencyInfoForJob retrieves deficiency information for a specific job func (s *Session) GetDeficiencyInfoForJob(jobID string) ([]map[string]interface{}, error) { resp, err := s.DoRequest("GET", fmt.Sprintf("/deficiency/%s", jobID), nil) diff --git a/internal/api/paperwork.go b/internal/api/paperwork.go new file mode 100644 index 0000000..798f041 --- /dev/null +++ b/internal/api/paperwork.go @@ -0,0 +1,210 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" +) + +// GetJobPaperwork retrieves a list of paperwork for a specific job +// This is a specialized endpoint to directly access paperwork +func (s *Session) GetJobPaperwork(jobID string) ([]map[string]interface{}, error) { + log.Printf("GetJobPaperwork: Fetching paperwork for job %s", jobID) + 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) + + // Set headers for better compatibility + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + + // Send the request + log.Printf("GetJobPaperwork: Sending request to %s", url) + resp, err := s.Client.Do(req) + if err != nil { + return nil, fmt.Errorf("error sending request: %v", err) + } + defer resp.Body.Close() + + // Read the full response body + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("error reading response body: %v", err) + } + + // Check for errors + if resp.StatusCode != http.StatusOK { + // Log detailed error information + log.Printf("GetJobPaperwork: API returned error %d for job %s", resp.StatusCode, jobID) + log.Printf("GetJobPaperwork: Error response: %s", string(body)) + return nil, fmt.Errorf("API returned error: %s - %s", resp.Status, string(body)) + } + + // Log response preview + responsePreview := string(body) + if len(responsePreview) > 200 { + responsePreview = responsePreview[:200] + "... [truncated]" + } + log.Printf("GetJobPaperwork: Response preview for job %s: %s", jobID, responsePreview) + + // Parse the response into a generic map + var result map[string]interface{} + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("error parsing response: %v", err) + } + + // Debug the response structure + keys := make([]string, 0) + for k := range result { + keys = append(keys, k) + } + log.Printf("GetJobPaperwork: Response root keys for job %s: %v", jobID, keys) + + // Check for data.attachments format (which is what the logs show) + paperworkItems := make([]map[string]interface{}, 0) + + // Check if data key exists + if data, ok := result["data"].(map[string]interface{}); ok { + dataKeys := make([]string, 0) + for k := range data { + dataKeys = append(dataKeys, k) + } + log.Printf("GetJobPaperwork: Data keys for job %s: %v", jobID, dataKeys) + + // Look for the attachments array in data + if attachments, ok := data["attachments"].([]interface{}); ok && len(attachments) > 0 { + log.Printf("GetJobPaperwork: Found %d attachments in data.attachments for job %s", + len(attachments), jobID) + + // Convert each attachment to a map and add to our collection + for _, attachment := range attachments { + if attachmentMap, ok := attachment.(map[string]interface{}); ok { + // Log the structure of the first attachment to better understand format + if len(paperworkItems) == 0 { + log.Printf("GetJobPaperwork: First attachment structure: %+v", attachmentMap) + } + paperworkItems = append(paperworkItems, attachmentMap) + } + } + + return paperworkItems, nil + } + + // Check for objects in data + if objects, ok := data["objects"].([]interface{}); ok && len(objects) > 0 { + log.Printf("GetJobPaperwork: Found %d items in data.objects for job %s", len(objects), jobID) + for _, obj := range objects { + if objMap, ok := obj.(map[string]interface{}); ok { + paperworkItems = append(paperworkItems, objMap) + } + } + } + + // Check for paperwork array in data + if paperwork, ok := data["paperwork"].([]interface{}); ok && len(paperwork) > 0 { + log.Printf("GetJobPaperwork: Found %d items in data.paperwork for job %s", len(paperwork), jobID) + for _, doc := range paperwork { + if docMap, ok := doc.(map[string]interface{}); ok { + paperworkItems = append(paperworkItems, docMap) + } + } + } + } + + // Also try the old formats + + // Check for objects at root level + if objects, ok := result["objects"].([]interface{}); ok && len(objects) > 0 { + log.Printf("GetJobPaperwork: Found %d items at root.objects for job %s", len(objects), jobID) + for _, obj := range objects { + if objMap, ok := obj.(map[string]interface{}); ok { + paperworkItems = append(paperworkItems, objMap) + } + } + } + + if len(paperworkItems) > 0 { + log.Printf("GetJobPaperwork: Extracted %d total paperwork items from response for job %s", + len(paperworkItems), jobID) + return paperworkItems, nil + } + + log.Printf("GetJobPaperwork: No paperwork items found in any format for job %s", jobID) + return nil, nil +} + +// CheckJobPermissions verifies if the current user has permissions to access the specified job +func (s *Session) CheckJobPermissions(jobID string) (bool, string, error) { + log.Printf("CheckJobPermissions: Checking permissions for job %s", jobID) + + // First try to get the job details - if this succeeds, the user has basic read access + url := fmt.Sprintf("%s/job/%s", BaseURL, jobID) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false, "Error creating request", err + } + + req.Header.Set("Cookie", s.Cookie) + resp, err := s.Client.Do(req) + if err != nil { + return false, "Error sending request", err + } + defer resp.Body.Close() + + // Read response for detailed error information + body, _ := io.ReadAll(resp.Body) + + // If we can't get the job, check the specific error + if resp.StatusCode != http.StatusOK { + errorMessage := string(body) + + // Check for permission-related errors + if resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusUnauthorized { + log.Printf("CheckJobPermissions: Permission denied for job %s: %s", jobID, errorMessage) + return false, fmt.Sprintf("Permission denied: %s", resp.Status), nil + } + + // Check for job not found + if resp.StatusCode == http.StatusNotFound { + log.Printf("CheckJobPermissions: Job %s not found: %s", jobID, errorMessage) + return false, "Job not found", nil + } + + // Other errors + log.Printf("CheckJobPermissions: Error accessing job %s: %s - %s", jobID, resp.Status, errorMessage) + return false, fmt.Sprintf("Error: %s", resp.Status), nil + } + + // If we get here, the user can at least view the job + // Now try to check if they can access paperwork + paperworkURL := fmt.Sprintf("%s/job/%s/paperwork", BaseURL, jobID) + paperworkReq, err := http.NewRequest("GET", paperworkURL, nil) + if err != nil { + return true, "Can access job, but error checking paperwork permissions", err + } + + paperworkReq.Header.Set("Cookie", s.Cookie) + paperworkResp, err := s.Client.Do(paperworkReq) + if err != nil { + return true, "Can access job, but error checking paperwork API", err + } + defer paperworkResp.Body.Close() + + if paperworkResp.StatusCode != http.StatusOK { + log.Printf("CheckJobPermissions: User can access job %s but paperwork API returned %d", + jobID, paperworkResp.StatusCode) + return true, "Can access job, but paperwork access denied", nil + } + + log.Printf("CheckJobPermissions: User has full access to job %s and its paperwork", jobID) + return true, "Full access", nil +} diff --git a/internal/handlers/web/document_remove.go b/internal/handlers/web/document_remove.go index 95640cc..3073a09 100644 --- a/internal/handlers/web/document_remove.go +++ b/internal/handlers/web/document_remove.go @@ -3,9 +3,13 @@ package web import ( "bytes" "encoding/csv" + "encoding/json" "fmt" + "io" "log" "net/http" + "regexp" + "sort" "strconv" "strings" "sync" @@ -29,6 +33,13 @@ func DocumentRemoveHandler(w http.ResponseWriter, r *http.Request) { data := map[string]interface{}{ "Title": "Document Removal", "Session": session, + "DocumentTypes": []map[string]string{ + {"value": "1", "label": "Job Paperwork"}, + {"value": "2", "label": "Job Vendor Bill"}, + {"value": "4", "label": "Generic Attachment"}, + {"value": "7", "label": "Blank Paperwork"}, + {"value": "14", "label": "Job Invoice"}, + }, } if r.Header.Get("HX-Request") == "true" { @@ -406,3 +417,760 @@ func renderErrorTemplate(w http.ResponseWriter, templateName, errorMsg string, j http.Error(w, "Internal Server Error", http.StatusInternalServerError) } } + +// Helper function to get map keys as a string for logging +func mapKeysStr(m map[string]interface{}) string { + if m == nil { + return "nil" + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + return strings.Join(keys, ", ") +} + +// BulkRemoveDocumentsHandler handles bulk removal of documents from multiple jobs +func BulkRemoveDocumentsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + session, ok := r.Context().Value("session").(*api.Session) + if !ok { + http.Error(w, "Unauthorized", http.StatusUnauthorized) + return + } + + // Parse the form + if err := r.ParseForm(); err != nil { + http.Error(w, fmt.Sprintf("Error parsing form: %v", err), http.StatusBadRequest) + return + } + + // Get job IDs from the form + 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 provided", http.StatusBadRequest) + return + } + + // Get document types to remove (optional) + var docTypes []string + if types := r.Form["documentTypes"]; len(types) > 0 { + docTypes = types + log.Printf("Filtering by document types: %v", docTypes) + } + + // Get filename patterns to match (optional) + var filenamePatterns []string + if patterns := r.FormValue("filenamePatterns"); patterns != "" { + filenamePatterns = strings.Split(patterns, ",") + for i, p := range filenamePatterns { + filenamePatterns[i] = strings.TrimSpace(p) + } + log.Printf("Filtering by filename patterns: %v", filenamePatterns) + } + + // Get age filter (optional) + var ageFilterDays int + if ageStr := r.FormValue("ageFilter"); ageStr != "" { + if days, err := strconv.Atoi(ageStr); err == nil && days > 0 { + ageFilterDays = days + log.Printf("Using age filter: older than %d days", ageFilterDays) + } + } + + // Calculate cutoff date if using age filter + var cutoffDate time.Time + if ageFilterDays > 0 { + cutoffDate = time.Now().AddDate(0, 0, -ageFilterDays) + } + + // Structure to track results + type BulkRemovalResult struct { + JobsProcessed int + JobsWithErrors int + TotalFiles int + SuccessCount int + ErrorCount int + JobResults []struct { + JobID string + FilesFound int + FilesRemoved int + Success bool + ErrorMsg string + Files []struct { + Name string + Success bool + Error string + } + } + } + + results := BulkRemovalResult{} + var wg sync.WaitGroup + var mu sync.Mutex + semaphore := make(chan struct{}, 5) // Limit concurrent API calls + + // Process each job + for _, jobID := range jobs { + wg.Add(1) + go func(jobID string) { + defer wg.Done() + + log.Printf("Processing job ID: %s for document removal", jobID) + + jobResult := struct { + JobID string + FilesFound int + FilesRemoved int + Success bool + ErrorMsg string + Files []struct { + Name string + Success bool + Error string + } + }{ + JobID: jobID, + Success: true, + Files: []struct { + Name string + Success bool + Error string + }{}, + } + + // Check job permissions first + log.Printf("**** JOB %s: Checking permissions...", jobID) + hasAccess, reason, err := session.CheckJobPermissions(jobID) + if err != nil { + log.Printf("**** JOB %s: Error checking permissions: %v", jobID, err) + } else { + log.Printf("**** JOB %s: Permission check result: access=%v, reason=%s", + jobID, hasAccess, reason) + } + + // Create the attachments array that will hold all found documents + var attachments []map[string]interface{} + + // Only proceed if we have access or couldn't determine access + if err != nil || hasAccess { + // Directly try to get paperwork using the specialized API + log.Printf("**** JOB %s: Using specialized paperwork API", jobID) + paperworkItems, err := session.GetJobPaperwork(jobID) + + if err != nil { + log.Printf("**** JOB %s: Error getting paperwork: %v", jobID, err) + } else if len(paperworkItems) > 0 { + log.Printf("**** JOB %s: GetJobPaperwork returned %d paperwork items", + jobID, len(paperworkItems)) + + // Add all paperwork items to attachments + for _, item := range paperworkItems { + log.Printf("**** JOB %s: Adding item from GetJobPaperwork: %v", + jobID, mapKeysStr(item)) + attachments = append(attachments, item) + } + } else { + log.Printf("**** JOB %s: No paperwork found using specialized API", jobID) + } + } else { + log.Printf("**** JOB %s: WARNING: No access to this job - reason: %s", jobID, reason) + mu.Lock() + jobResult.Success = false + jobResult.ErrorMsg = fmt.Sprintf("Cannot access job: %s", reason) + results.JobResults = append(results.JobResults, jobResult) + results.JobsWithErrors++ + mu.Unlock() + return + } + + // Try alternate method (always try both methods to see what data is available) + log.Printf("**** JOB %s: Retrieving attachments using GetAttachmentsForJob", jobID) + apiResponse, err := session.GetAttachmentsForJob(jobID) + if err != nil { + log.Printf("**** JOB %s: Error in GetAttachmentsForJob: %v", jobID, err) + } else { + // Log the structure of the response to understand format + rootKeys := make([]string, 0) + for k := range apiResponse { + rootKeys = append(rootKeys, k) + } + log.Printf("**** JOB %s: GetAttachmentsForJob returned response with root keys: %s", + jobID, strings.Join(rootKeys, ", ")) + + // Check if we have a data object + if data, ok := apiResponse["data"].(map[string]interface{}); ok { + dataKeys := make([]string, 0) + for k := range data { + dataKeys = append(dataKeys, k) + } + log.Printf("**** JOB %s: data object keys: %s", + jobID, strings.Join(dataKeys, ", ")) + } + + // Check if we have paperwork_data + if paperworkData, ok := apiResponse["paperwork_data"].(map[string]interface{}); ok { + dataKeys := make([]string, 0) + for k := range paperworkData { + dataKeys = append(dataKeys, k) + } + log.Printf("**** JOB %s: paperwork_data keys: %s", + jobID, strings.Join(dataKeys, ", ")) + + // Check if paperwork_data.data.attachments exists + if paperworkDataInner, ok := paperworkData["data"].(map[string]interface{}); ok { + if attachmentsArray, ok := paperworkDataInner["attachments"].([]interface{}); ok && len(attachmentsArray) > 0 { + log.Printf("**** JOB %s: Found %d attachments in paperwork_data.data.attachments", + jobID, len(attachmentsArray)) + + // Process each attachment and add to our collection + for _, attachment := range attachmentsArray { + if attachmentMap, ok := attachment.(map[string]interface{}); ok { + attachments = append(attachments, attachmentMap) + } + } + } + } + } + + log.Printf("**** JOB %s: Total attachments gathered: %d", jobID, len(attachments)) + + if len(attachments) == 0 { + log.Printf("**** JOB %s: No attachments found yet, trying direct paperwork endpoint") + + // Make a direct call to the paperwork endpoint + paperworkURL := fmt.Sprintf("%s/job/%s/paperwork", api.BaseURL, jobID) + paperworkReq, err := http.NewRequest("GET", paperworkURL, nil) + if err == nil { + paperworkReq.Header.Set("Cookie", session.Cookie) + paperworkReq.Header.Set("Accept", "application/json") + + log.Printf("**** JOB %s: Sending direct request to %s", jobID, paperworkURL) + paperworkResp, err := session.Client.Do(paperworkReq) + + if err == nil && paperworkResp.StatusCode == http.StatusOK { + defer paperworkResp.Body.Close() + paperworkBody, _ := io.ReadAll(paperworkResp.Body) + + // Log preview of the response + responsePreview := string(paperworkBody) + if len(responsePreview) > 200 { + responsePreview = responsePreview[:200] + "... [truncated]" + } + log.Printf("**** JOB %s: Direct paperwork response preview: %s", jobID, responsePreview) + + // Parse the response + var paperworkResult map[string]interface{} + if err := json.Unmarshal(paperworkBody, &paperworkResult); err == nil { + // Log the structure of the response + rootKeys := make([]string, 0) + for k := range paperworkResult { + rootKeys = append(rootKeys, k) + } + log.Printf("**** JOB %s: Direct paperwork response keys: %s", + jobID, strings.Join(rootKeys, ", ")) + + // Check for data.attachments + if data, ok := paperworkResult["data"].(map[string]interface{}); ok { + dataKeys := make([]string, 0) + for k := range data { + dataKeys = append(dataKeys, k) + } + log.Printf("**** JOB %s: Direct paperwork data keys: %s", + jobID, strings.Join(dataKeys, ", ")) + + // Extract attachments from data.attachments + if attachmentsArray, ok := data["attachments"].([]interface{}); ok && len(attachmentsArray) > 0 { + log.Printf("**** JOB %s: Found %d attachments in direct paperwork response", + jobID, len(attachmentsArray)) + + // Loop through the attachments and add to our collection + for i, attachment := range attachmentsArray { + if attachmentMap, ok := attachment.(map[string]interface{}); ok { + // Log details of the first attachment to understand the structure + if i == 0 { + log.Printf("**** JOB %s: First attachment structure: %+v", jobID, attachmentMap) + attKeys := make([]string, 0) + for k := range attachmentMap { + attKeys = append(attKeys, k) + } + log.Printf("**** JOB %s: First attachment keys: %s", jobID, strings.Join(attKeys, ", ")) + } + + // Add to our attachments collection + attachments = append(attachments, attachmentMap) + } + } + } + } + } + } + } + + // Log attachment count after direct endpoint + log.Printf("**** JOB %s: Attachments found after direct paperwork call: %d", jobID, len(attachments)) + + // Now actually apply the filters + filteredAttachments := make([]map[string]interface{}, 0) + + // Process each attachment + for _, attachment := range attachments { + log.Printf("**** JOB %s: Processing attachment ID: %v", jobID, attachment["id"]) + + // Check document types filter + if len(docTypes) > 0 { + typeMatches := false + + // Log all attachment details for debugging + logAttachmentDetails(jobID, attachment) + + // Log docTypes array as it comes from the form + log.Printf("**** JOB %s: Doc types from form: %v", jobID, docTypes) + + // Get all possible attachment type info + var purposeId float64 + var purpose string + var typeValue string + + // Check all possible type fields + if val, ok := attachment["purposeId"].(float64); ok { + purposeId = val + log.Printf("**** JOB %s: Found purposeId=%.0f", jobID, purposeId) + } + + if val, ok := attachment["purpose"].(string); ok { + purpose = val + log.Printf("**** JOB %s: Found purpose=%s", jobID, purpose) + } + + if val, ok := attachment["type"].(string); ok { + typeValue = val + log.Printf("**** JOB %s: Found type=%s", jobID, typeValue) + } + + // Now try to match with each document type from form + for _, docType := range docTypes { + // Clean up the doc type (remove leading zeros) + docTypeClean := strings.TrimLeft(docType, "0") + + // Try to convert the cleaned doc type to a number + if docTypeNum, err := strconv.ParseFloat(docTypeClean, 64); err == nil { + // Compare with purposeId + if purposeId > 0 && purposeId == docTypeNum { + log.Printf("**** JOB %s: MATCH! docType=%s matches purposeId=%.0f", + jobID, docType, purposeId) + typeMatches = true + break + } + } + + // Try string comparisons if no match yet + if !typeMatches && purpose != "" { + if docType == purpose || docTypeClean == purpose { + log.Printf("**** JOB %s: MATCH! docType=%s matches purpose=%s", + jobID, docType, purpose) + typeMatches = true + break + } + } + + if !typeMatches && typeValue != "" { + if docType == typeValue || docTypeClean == typeValue { + log.Printf("**** JOB %s: MATCH! docType=%s matches type=%s", + jobID, docType, typeValue) + typeMatches = true + break + } + } + } + + if !typeMatches { + log.Printf("**** JOB %s: No type match found, skipping attachment", jobID) + continue + } + } + + // Get document name/filename + var filename string + if nameVal, ok := attachment["fileName"].(string); ok { + filename = nameVal + } else if nameVal, ok := attachment["name"].(string); ok { + filename = nameVal + } else if nameVal, ok := attachment["description"].(string); ok { + filename = nameVal + } + + log.Printf("**** JOB %s: Attachment filename: %s", jobID, filename) + + // Check filename pattern + if len(filenamePatterns) > 0 && !matchesAnyPattern(filename, filenamePatterns) { + log.Printf("**** JOB %s: Skipping attachment - filename '%s' doesn't match patterns", + jobID, filename) + continue + } + + // Check age filter if applicable + if ageFilterDays > 0 { + // Get creation time + var createdAt time.Time + var createdOn string + var hasDate bool + + // Try to get the creation date + if created, ok := attachment["createdOn"].(string); ok { + createdOn = created + hasDate = true + } else if created, ok := attachment["created"].(string); ok { + createdOn = created + hasDate = true + } else if lastModified, ok := attachment["lastModified"].(string); ok { + createdOn = lastModified + hasDate = true + } else if createdVal, ok := attachment["created"].(float64); ok { + createdAt = time.Unix(int64(createdVal), 0) + createdOn = createdAt.Format(time.RFC3339) + hasDate = true + } + + if hasDate { + if parsedTime, err := time.Parse(time.RFC3339, createdOn); err == nil { + createdAt = parsedTime + if createdAt.After(cutoffDate) { + log.Printf("Skipping attachment %s - created on %s is newer than cutoff %s", + filename, createdAt.Format("2006-01-02"), cutoffDate.Format("2006-01-02")) + continue // Skip if not old enough + } + } + } + } + + // Use a new variable with a different name for the log message + var typeStr string = "unknown" + if pId, ok := attachment["purposeId"].(float64); ok { + typeStr = fmt.Sprintf("%.0f", pId) + } + + log.Printf("Attachment %s (type: %s) matches all criteria - queued for deletion", + filename, typeStr) + + // If we got here, the attachment passes all filters + filteredAttachments = append(filteredAttachments, attachment) + } + + // Update the attachments with the filtered list + attachments = filteredAttachments + log.Printf("**** JOB %s: Final attachments to process after filtering: %d", jobID, len(attachments)) + } + + log.Printf("**** JOB %s: Final total attachments: %d", jobID, len(attachments)) + + if len(attachments) == 0 { + log.Printf("**** JOB %s: WARNING! No attachments found after all retrieval attempts") + } + } + + // Filter attachments based on criteria + var attachmentsToDelete []map[string]interface{} + for _, attachment := range attachments { + // Get the attachment ID + attachmentIDRaw, idOk := attachment["id"].(float64) + if !idOk { + log.Printf("Skipping attachment - missing ID field") + continue + } + attachmentIDStr := fmt.Sprintf("%.0f", attachmentIDRaw) + + // Get the filename + var filename string + if nameVal, ok := attachment["fileName"].(string); ok { + filename = nameVal + } else if nameVal, ok := attachment["name"].(string); ok { + filename = nameVal + } else if nameVal, ok := attachment["description"].(string); ok { + filename = nameVal + } + + if filename == "" { + log.Printf("Attachment %s is missing filename information", attachmentIDStr) + continue + } + + log.Printf("Processing attachment ID: %s, filename: %s", attachmentIDStr, filename) + + // Check document type using purposeId which IS available in the data + if len(docTypes) > 0 { + typeMatches := false + + // Get purposeId + var purposeId float64 + if val, ok := attachment["purposeId"].(float64); ok { + purposeId = val + log.Printf("Attachment %s has purposeId=%.0f", attachmentIDStr, purposeId) + } else { + log.Printf("Attachment %s has no purposeId field", attachmentIDStr) + continue + } + + // Compare with selected document types + for _, docType := range docTypes { + // Form uses "01", "02", etc. but API uses 1, 2, etc. - handle both + docTypeClean := strings.TrimLeft(docType, "0") + if docTypeInt, err := strconv.Atoi(docTypeClean); err == nil { + if float64(docTypeInt) == purposeId { + log.Printf("✓ Type match for attachment %s: form value %s matches purposeId %.0f", + attachmentIDStr, docType, purposeId) + typeMatches = true + break + } + } + } + + if !typeMatches { + // Get purposeId for error message + var purposeVal float64 + if val, ok := attachment["purposeId"].(float64); ok { + purposeVal = val + log.Printf("Skipping attachment %s - purposeId %.0f doesn't match any selected types: %v", + attachmentIDStr, purposeVal, docTypes) + } else { + log.Printf("Skipping attachment %s - type doesn't match any selected types: %v", + attachmentIDStr, docTypes) + } + continue + } + } + + // Comment out problematic log line + // log.Printf("Attachment %s (type: %v, created: %s) matches all criteria - queued for deletion", + // filename, purposeId, attachment["createdOn"]) + + // Log that we found an attachment to delete + log.Printf("Attachment %s matches criteria - will be deleted", filename) + + // This attachment matches all criteria + attachmentsToDelete = append(attachmentsToDelete, attachment) + } + + jobResult.FilesFound = len(attachmentsToDelete) + + // Process deletions with rate limiting + var deletionWg sync.WaitGroup + for _, attachment := range attachmentsToDelete { + // Use a separate goroutine for each deletion with its own semaphore slot + deletionWg.Add(1) + + // Important: Create a copy of the attachment for the goroutine to avoid + // sharing the loop variable which can cause race conditions + attachmentCopy := attachment + + go func(att map[string]interface{}) { + defer deletionWg.Done() + + // Acquire a semaphore slot for this deletion operation + semaphore <- struct{}{} + defer func() { <-semaphore }() // Release when done + + attachmentIDFloat := att["id"].(float64) + attachmentIDStr := fmt.Sprintf("%.0f", attachmentIDFloat) // Convert to string without decimal + filename := att["description"].(string) + + fileResult := struct { + Name string + Success bool + Error string + }{ + Name: filename, + } + + // Delete the attachment + err := session.DeleteAttachment(attachmentIDStr) + + mu.Lock() + defer mu.Unlock() + + if err != nil { + fileResult.Success = false + fileResult.Error = err.Error() + log.Printf("Error deleting attachment %s: %v", filename, err) + jobResult.Success = false + } else { + fileResult.Success = true + jobResult.FilesRemoved++ + log.Printf("Successfully deleted attachment %s", filename) + } + + jobResult.Files = append(jobResult.Files, fileResult) + + // Add a slight delay to avoid overwhelming the API + time.Sleep(300 * time.Millisecond) + }(attachmentCopy) + } + + // Wait for all deletions for this job to complete + deletionWg.Wait() + + mu.Lock() + results.JobsProcessed++ + if jobResult.Success { + results.SuccessCount += jobResult.FilesRemoved + } else { + results.ErrorCount += (jobResult.FilesFound - jobResult.FilesRemoved) + } + results.TotalFiles += jobResult.FilesFound + results.JobResults = append(results.JobResults, jobResult) + mu.Unlock() + }(jobID) + } + + // Wait for all jobs to complete + wg.Wait() + + // Generate HTML for results + var resultHTML bytes.Buffer + + // Add summary section + resultHTML.WriteString("
") + resultHTML.WriteString("

Document Removal Results

") + resultHTML.WriteString("
") + + // Total jobs stat + resultHTML.WriteString("
") + resultHTML.WriteString(fmt.Sprintf("
%d
", results.JobsProcessed)) + resultHTML.WriteString("
Total Jobs
") + resultHTML.WriteString("
") + + // Success stat + resultHTML.WriteString("
") + resultHTML.WriteString(fmt.Sprintf("
%d
", results.SuccessCount)) + resultHTML.WriteString("
Successful Removals
") + resultHTML.WriteString("
") + + // Failure stat + resultHTML.WriteString("
") + resultHTML.WriteString(fmt.Sprintf("
%d
", results.ErrorCount)) + resultHTML.WriteString("
Failed Removals
") + resultHTML.WriteString("
") + + // File count stat + resultHTML.WriteString("
") + resultHTML.WriteString(fmt.Sprintf("
%d
", results.TotalFiles)) + resultHTML.WriteString("
Files Processed
") + resultHTML.WriteString("
") + + resultHTML.WriteString("
") // End of upload-stats + + // Add completion message + if results.ErrorCount == 0 { + resultHTML.WriteString("

All documents were successfully removed from ServiceTrade!

") + } else { + resultHTML.WriteString("

Some documents failed to be removed. See details below.

") + } + + resultHTML.WriteString("
") // End of upload-summary + + // Add detailed job results + resultHTML.WriteString("
") + + // Sort job IDs for consistent display + sort.Slice(results.JobResults, func(i, j int) bool { + return results.JobResults[i].JobID < results.JobResults[j].JobID + }) + + for _, jobResult := range results.JobResults { + // Job result row + jobClass := "success" + if !jobResult.Success { + jobClass = "error" + } + + resultHTML.WriteString(fmt.Sprintf("
", jobClass)) + resultHTML.WriteString(fmt.Sprintf("Job #%s", jobResult.JobID)) + + if jobResult.ErrorMsg != "" { + resultHTML.WriteString(fmt.Sprintf("
%s
", jobResult.ErrorMsg)) + } else { + resultHTML.WriteString(fmt.Sprintf("
Found %d document(s), removed %d
", + jobResult.FilesFound, jobResult.FilesRemoved)) + } + + // File results + if len(jobResult.Files) > 0 { + resultHTML.WriteString("
") + + for _, file := range jobResult.Files { + fileClass := "success" + icon := "✓" + message := "Successfully removed" + + if !file.Success { + fileClass = "error" + icon = "✗" + message = file.Error + } + + resultHTML.WriteString(fmt.Sprintf("
", fileClass)) + resultHTML.WriteString(fmt.Sprintf("%s", icon)) + resultHTML.WriteString(fmt.Sprintf("%s:", file.Name)) + resultHTML.WriteString(fmt.Sprintf("%s", message)) + resultHTML.WriteString("
") + } + + resultHTML.WriteString("
") // End of file-results + } else { + resultHTML.WriteString("

No files processed for this job.

") + } + + resultHTML.WriteString("
") // End of job-result + } + + resultHTML.WriteString("
") // End of job-results + + w.Header().Set("Content-Type", "text/html") + w.Write(resultHTML.Bytes()) +} + +// Helper function to check if a string is in a slice +func stringInSlice(s string, slice []string) bool { + for _, item := range slice { + if item == s { + return true + } + } + return false +} + +// Helper function to check if a string matches any pattern in a slice +func matchesAnyPattern(s string, patterns []string) bool { + for _, pattern := range patterns { + match, _ := regexp.MatchString("(?i)"+pattern, s) + if match { + return true + } + } + return false +} + +// Enhanced debugging function to help understand attachment structure +func logAttachmentDetails(jobID string, attachment map[string]interface{}) { + // Create a detailed view of the attachment + attachmentID := "unknown" + if id, ok := attachment["id"].(float64); ok { + attachmentID = fmt.Sprintf("%.0f", id) + } + + log.Printf("***** DETAILED ATTACHMENT %s *****", attachmentID) + for key, value := range attachment { + log.Printf(" %s = %v (type: %T)", key, value, value) + } + log.Printf("***** END ATTACHMENT DETAILS *****") +} diff --git a/static/css/upload.css b/static/css/upload.css index 0fa12f4..766050a 100644 --- a/static/css/upload.css +++ b/static/css/upload.css @@ -280,4 +280,73 @@ :root.dark-theme .loading-indicator { border-color: rgba(255, 255, 255, 0.1); border-top-color: var(--btn-primary-bg); +} + +/* Tab buttons */ +.tab-buttons { + display: flex; + margin-bottom: 1rem; + border-bottom: 1px solid var(--dropdown-border); +} + +.tab-button { + padding: 0.5rem 1rem; + background: var(--card-bg); + border: 1px solid var(--dropdown-border); + border-bottom: none; + border-radius: 4px 4px 0 0; + color: var(--content-text); + cursor: pointer; + margin-right: 0.5rem; + position: relative; + bottom: -1px; +} + +.tab-button.active { + background: var(--content-bg); + border-bottom: 1px solid var(--content-bg); + font-weight: bold; +} + +/* Checkbox styles */ +.checkbox-group { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 1.5rem; + margin: 0.5rem 0 1rem 0; +} + +.checkbox-item { + display: flex; + align-items: center; +} + +.checkbox-item input[type="checkbox"] { + margin-right: 0.5rem; +} + +/* Form groups */ +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + color: var(--label-color); + font-weight: bold; +} + +.form-actions { + margin-top: 1.5rem; + display: flex; + align-items: center; + gap: 1rem; +} + +/* Job summary in results */ +.job-summary { + margin: 0.5rem 0; + font-size: 0.9rem; + color: var(--soft-text); } \ No newline at end of file diff --git a/templates/partials/document_remove.html b/templates/partials/document_remove.html index 64f6040..da2c415 100644 --- a/templates/partials/document_remove.html +++ b/templates/partials/document_remove.html @@ -11,7 +11,74 @@
- {{template "document_remove_form" .}} + +
+ + +
+ +
+ {{template "document_remove_form" .}} +
+ +
+ + {{end}} \ No newline at end of file