// Summary and reporting persistence for Phase 7 (FR-10). // // The daily summary is built from the same classification data the triage list // uses; it is stored once per date in the `summaries` table (AGENTS.md §7) and // then delivered by email through Apprise. package store import ( "context" "database/sql" "errors" "fmt" "strings" ) // CategoryCount is one bucket of the inbox overview: a category name and how // many messages currently sit in it. The empty category is the unclassified // bucket. type CategoryCount struct { Category string Count int } // CountByCategory returns message counts for one folder, grouped by each // message's latest classification category. Messages with no usable // classification are counted under the empty category name. func (s *Store) CountByCategory(ctx context.Context, folder string) ([]CategoryCount, error) { const q = ` SELECT COALESCE(c.category, ''), COUNT(*) FROM messages m LEFT JOIN classifications c ON c.id = ( SELECT id FROM classifications WHERE message_id = m.id ORDER BY id DESC LIMIT 1 ) WHERE (? = '' OR m.folder = ?) GROUP BY 1 ORDER BY 2 DESC, 1 ASC` rows, err := s.db.QueryContext(ctx, q, folder, folder) if err != nil { return nil, fmt.Errorf("count by category: %w", err) } defer rows.Close() var out []CategoryCount for rows.Next() { var c CategoryCount if err := rows.Scan(&c.Category, &c.Count); err != nil { return nil, fmt.Errorf("scan category count: %w", err) } out = append(out, c) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("count by category: %w", err) } return out, nil } // MessagesByCategory returns the newest messages in a folder whose latest // classification is one of categories, highest importance first. It backs the // "what needs attention" list in the daily summary and the Hub answer. func (s *Store) MessagesByCategory(ctx context.Context, folder string, categories []string, limit int) ([]MessageListItem, error) { if len(categories) == 0 { return nil, nil } if limit < 1 { limit = 20 } placeholders := make([]string, len(categories)) args := make([]any, 0, len(categories)+2) for i, c := range categories { placeholders[i] = "?" args = append(args, c) } // The importance rank puts high first; an unset importance sorts last. rank := `CASE LOWER(COALESCE(c.importance, '')) WHEN 'high' THEN 0 WHEN 'medium' THEN 1 WHEN 'low' THEN 2 ELSE 3 END` folderClause := "" if folder != "" { folderClause = "AND m.folder = ?" args = append(args, folder) } args = append(args, limit) q := ` SELECT m.id, m.gmail_id, COALESCE(m.thread_id, ''), COALESCE(m.from_addr, ''), COALESCE(m.to_addr, ''), COALESCE(m.subject, ''), COALESCE(m.body_text, ''), COALESCE(m.source_address, ''), COALESCE(m.date_sent, ''), m.received_at, COALESCE(m.imap_uid, 0), COALESCE(m.imap_uidvalidity, 0), COALESCE(m.folder, 'inbox'), COALESCE(m.mailbox, 'INBOX'), COALESCE(c.category, ''), COALESCE(c.importance, ''), COALESCE(c.action, ''), COALESCE(c.confidence, 0) FROM messages m LEFT JOIN classifications c ON c.id = ( SELECT id FROM classifications WHERE message_id = m.id ORDER BY id DESC LIMIT 1 ) WHERE c.category IN (` + strings.Join(placeholders, ",") + `) ` + folderClause + ` ORDER BY ` + rank + `, COALESCE(NULLIF(m.date_sent, ''), m.received_at) DESC, m.id DESC LIMIT ?` rows, err := s.db.QueryContext(ctx, q, args...) if err != nil { return nil, fmt.Errorf("messages by category: %w", err) } defer rows.Close() var out []MessageListItem for rows.Next() { var ( item MessageListItem dateSent string received string ) if err := rows.Scan( &item.ID, &item.GmailID, &item.ThreadID, &item.FromAddr, &item.ToAddr, &item.Subject, &item.BodyText, &item.SourceAddress, &dateSent, &received, &item.IMAPUID, &item.IMAPUIDValidity, &item.Folder, &item.Mailbox, &item.Category, &item.Importance, &item.Action, &item.Confidence, ); err != nil { return nil, fmt.Errorf("scan category message: %w", err) } item.DateSent = parseSQLTime(dateSent) item.ReceivedAt = parseSQLTime(received) item.HasClassification = item.Category != "" out = append(out, item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("messages by category: %w", err) } return out, nil } // Summary is one stored daily summary (FR-10). type Summary struct { ID int64 Date string Text string DeliveredTo string CreatedAt string } // SaveSummary stores the summary for a date, replacing an earlier build for the // same date so re-running the job does not duplicate rows. It returns the row id. func (s *Store) SaveSummary(ctx context.Context, date, text, deliveredTo string) (int64, error) { const q = ` INSERT INTO summaries (date, text, delivered_to) VALUES (?, ?, ?) ON CONFLICT (date) DO UPDATE SET text = excluded.text, delivered_to = excluded.delivered_to` if _, err := s.db.ExecContext(ctx, q, date, text, nullString(deliveredTo)); err != nil { return 0, fmt.Errorf("save summary %s: %w", date, err) } var id int64 if err := s.db.QueryRowContext(ctx, `SELECT id FROM summaries WHERE date = ?`, date).Scan(&id); err != nil { return 0, fmt.Errorf("read summary id for %s: %w", date, err) } return id, nil } // MarkSummaryDelivered records the recipient once a summary has been emailed. func (s *Store) MarkSummaryDelivered(ctx context.Context, id int64, deliveredTo string) error { if _, err := s.db.ExecContext(ctx, `UPDATE summaries SET delivered_to = ? WHERE id = ?`, nullString(deliveredTo), id); err != nil { return fmt.Errorf("mark summary %d delivered: %w", id, err) } return nil } // SummaryByDate loads one summary by its YYYY-MM-DD date. ok is false when none // exists. func (s *Store) SummaryByDate(ctx context.Context, date string) (Summary, bool, error) { row := s.db.QueryRowContext(ctx, ` SELECT id, date, text, COALESCE(delivered_to, ''), created_at FROM summaries WHERE date = ?`, date) var sum Summary if err := row.Scan(&sum.ID, &sum.Date, &sum.Text, &sum.DeliveredTo, &sum.CreatedAt); err != nil { if errors.Is(err, sql.ErrNoRows) { return Summary{}, false, nil } return Summary{}, false, fmt.Errorf("read summary %s: %w", date, err) } return sum, true, nil } // ListSummaries returns stored summaries, newest date first. func (s *Store) ListSummaries(ctx context.Context, limit int) ([]Summary, error) { if limit < 1 { limit = 30 } rows, err := s.db.QueryContext(ctx, ` SELECT id, date, text, COALESCE(delivered_to, ''), created_at FROM summaries ORDER BY date DESC LIMIT ?`, limit) if err != nil { return nil, fmt.Errorf("list summaries: %w", err) } defer rows.Close() var out []Summary for rows.Next() { var sum Summary if err := rows.Scan(&sum.ID, &sum.Date, &sum.Text, &sum.DeliveredTo, &sum.CreatedAt); err != nil { return nil, fmt.Errorf("scan summary: %w", err) } out = append(out, sum) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list summaries: %w", err) } return out, nil } // DeleteSummary removes one stored report (AGENTS.md §5 "delete-report"). func (s *Store) DeleteSummary(ctx context.Context, id int64) error { if _, err := s.db.ExecContext(ctx, `DELETE FROM summaries WHERE id = ?`, id); err != nil { return fmt.Errorf("delete summary %d: %w", id, err) } return nil }