package storage import ( "database/sql" "fmt" "time" ) // ClassificationRow is a stored AI classification of a receipt, joined with the // receipt's final field values so the caller can derive which fields the user // overrode (a "miss"). The response blob is opaque diagnostic data here — the web // layer parses it; storage never interprets it. type ClassificationRow struct { ReceiptID string Model string ResponseJSON string CreatedAt time.Time Reviewed bool ReviewedAt *time.Time Resolution string // Final receipt values, for deriving overrides and display. FinalAmountCents int64 FinalReceiptDate time.Time FinalCategoryID int64 FinalPersonID *int64 } // InsertClassification records the AI suggestion (its response blob + model) for a // receipt. One row per AI-run upload; skip-AI/disabled uploads insert nothing. func (s *Store) InsertClassification(receiptID, model, responseJSON string, createdAt time.Time) error { _, err := s.db.Exec( `INSERT INTO classifications(receipt_id, model, response_json, created_at) VALUES (?, ?, ?, ?)`, receiptID, model, responseJSON, createdAt.UTC().Format(rfc3339)) if err != nil { return fmt.Errorf("insert classification: %w", err) } return nil } const classificationSelect = ` SELECT c.receipt_id, c.model, c.response_json, c.created_at, c.reviewed, c.reviewed_at, c.resolution, r.amount_cents, r.receipt_date, r.category_id, r.person_id FROM classifications c JOIN receipts r ON r.id = c.receipt_id` // ListUnreviewedClassifications returns not-yet-reviewed classifications of live // receipts, newest first. Whether each is an actual "miss" is derived by the caller // from the blob vs. the final fields. func (s *Store) ListUnreviewedClassifications() ([]ClassificationRow, error) { rows, err := s.db.Query(classificationSelect + ` WHERE c.reviewed = 0 AND r.deleted_at IS NULL ORDER BY c.created_at DESC`) if err != nil { return nil, fmt.Errorf("list classifications: %w", err) } defer rows.Close() var out []ClassificationRow for rows.Next() { c, err := scanClassification(rows) if err != nil { return nil, err } out = append(out, c) } return out, rows.Err() } // GetClassification returns the classification for one receipt. func (s *Store) GetClassification(receiptID string) (ClassificationRow, error) { return scanClassification(s.db.QueryRow(classificationSelect+` WHERE c.receipt_id = ?`, receiptID)) } func scanClassification(sc rowScanner) (ClassificationRow, error) { var c ClassificationRow var createdAt, receiptDate string var reviewed int64 var reviewedAt, resolution sql.NullString var personID sql.NullInt64 if err := sc.Scan(&c.ReceiptID, &c.Model, &c.ResponseJSON, &createdAt, &reviewed, &reviewedAt, &resolution, &c.FinalAmountCents, &receiptDate, &c.FinalCategoryID, &personID); err != nil { return ClassificationRow{}, fmt.Errorf("scan classification: %w", err) } c.CreatedAt, _ = time.Parse(rfc3339, createdAt) c.FinalReceiptDate, _ = time.Parse(rfc3339, receiptDate) c.Reviewed = reviewed != 0 if reviewedAt.Valid { if t, err := time.Parse(rfc3339, reviewedAt.String); err == nil { c.ReviewedAt = &t } } if resolution.Valid { c.Resolution = resolution.String } if personID.Valid { c.FinalPersonID = &personID.Int64 } return c, nil } // MarkClassificationReviewed flags a classification reviewed with the given // resolution and links the notes the user credited with fixing it. func (s *Store) MarkClassificationReviewed(receiptID, resolution string, fixNoteIDs []int64) error { tx, err := s.db.Begin() if err != nil { return fmt.Errorf("begin review: %w", err) } defer tx.Rollback() if _, err := tx.Exec( `UPDATE classifications SET reviewed = 1, reviewed_at = ?, resolution = ? WHERE receipt_id = ?`, time.Now().UTC().Format(rfc3339), resolution, receiptID); err != nil { return fmt.Errorf("mark reviewed: %w", err) } for _, nid := range fixNoteIDs { if _, err := tx.Exec( `INSERT OR IGNORE INTO miss_fixes(receipt_id, note_id) VALUES (?, ?)`, receiptID, nid); err != nil { return fmt.Errorf("link fix note: %w", err) } } return tx.Commit() }