package web import ( "encoding/json" "io" "net/http" "strings" "time" "maisym.com/hsa/internal/storage" ) // classifyResponse is the JSON returned to the upload page to pre-fill the form. // IDs map the classifier's label suggestions onto the live lookup rows; they are // null when the classifier returned nothing or the label has no matching row. type classifyResponse struct { Amount *string `json:"amount"` Date *string `json:"date"` CategoryID *int64 `json:"category_id"` PersonID *int64 `json:"person_id"` Category string `json:"category"` // label, for display Person string `json:"person"` // label, for display RawName string `json:"raw_name"` RawDate string `json:"raw_date"` RawAmount string `json:"raw_amount"` } // handleClassify accepts an uploaded receipt image and returns suggested form // values from the AI classifier. It mutates no state — the user still reviews and // submits via POST /upload. func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) { if s.classifier == nil { http.Error(w, "classification not configured", http.StatusServiceUnavailable) return } r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxUploadBytes) if err := r.ParseMultipartForm(10 << 20); err != nil { http.Error(w, "upload too large or malformed", http.StatusBadRequest) return } file, _, err := r.FormFile("receipt") if err != nil { http.Error(w, "attach a receipt image or PDF", http.StatusBadRequest) return } defer file.Close() data, err := io.ReadAll(file) if err != nil { http.Error(w, "could not read the uploaded file", http.StatusBadRequest) return } mimeType := detectMime(data) if !allowedMime(mimeType) { http.Error(w, "only images and PDFs are allowed", http.StatusUnsupportedMediaType) return } sug, err := s.classifier.Classify(r.Context(), time.Now(), data, mimeType) if err != nil { s.serverError(w, "classify receipt", err) return } cats, people, err := s.lookups() if err != nil { s.serverError(w, "load lookups", err) return } out := classifyResponse{ Amount: sug.Amount, Date: sug.Date, Category: sug.Category, RawName: sug.RawName, RawDate: sug.RawDate, RawAmount: sug.RawAmount, } if id, ok := idForLabel(sug.Category, cats); ok { out.CategoryID = &id } if sug.Person != nil { if id, ok := idForLabel(*sug.Person, people); ok { out.PersonID = &id out.Person = *sug.Person } } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(out) } // idForLabel finds a lookup row by case-insensitive label match. func idForLabel(label string, set []storage.Lookup) (int64, bool) { label = strings.TrimSpace(label) for _, l := range set { if strings.EqualFold(l.Label, label) { return l.ID, true } } return 0, false }