// Reply-draft persistence for Phase 5 (FR-8). // // A draft belongs to one message. It is rewritten in place while Sam edits it // and marked `sent` after a successful SMTP send. Only the newest draft per // message is shown in the UI; history is kept in the actions_log (FR-13). package store import ( "context" "database/sql" "errors" "fmt" "time" ) // Draft statuses. A draft moves draft -> sent once, on a successful send. const ( DraftStatusDraft = "draft" DraftStatusSent = "sent" ) // Draft is one reply draft row. type Draft struct { ID int64 MessageID int64 Body string TemplateID string Status string SentAt time.Time CreatedAt time.Time UpdatedAt time.Time } // SaveDraft inserts a new draft for a message and returns it. func (s *Store) SaveDraft(ctx context.Context, messageID int64, body, templateID string) (Draft, error) { const q = `INSERT INTO drafts (message_id, body, template_id, status) VALUES (?, ?, ?, 'draft')` res, err := s.db.ExecContext(ctx, q, messageID, body, nullString(templateID)) if err != nil { return Draft{}, fmt.Errorf("save draft for message %d: %w", messageID, err) } id, err := res.LastInsertId() if err != nil { return Draft{}, fmt.Errorf("read draft id: %w", err) } return s.DraftByID(ctx, id) } // DraftByID loads one draft. func (s *Store) DraftByID(ctx context.Context, id int64) (Draft, error) { const q = ` SELECT id, message_id, body, COALESCE(template_id, ''), status, COALESCE(sent_at, ''), created_at, updated_at FROM drafts WHERE id = ?` return scanDraft(s.db.QueryRowContext(ctx, q, id)) } // LatestDraft returns the newest draft for a message. ok is false when the // message has no draft yet. func (s *Store) LatestDraft(ctx context.Context, messageID int64) (Draft, bool, error) { const q = ` SELECT id, message_id, body, COALESCE(template_id, ''), status, COALESCE(sent_at, ''), created_at, updated_at FROM drafts WHERE message_id = ? ORDER BY id DESC LIMIT 1` d, err := scanDraft(s.db.QueryRowContext(ctx, q, messageID)) if errors.Is(err, sql.ErrNoRows) { return Draft{}, false, nil } if err != nil { return Draft{}, false, err } return d, true, nil } // UpdateDraftBody replaces the body of a draft Sam edited and bumps updated_at. func (s *Store) UpdateDraftBody(ctx context.Context, id int64, body string) error { const q = `UPDATE drafts SET body = ?, updated_at = datetime('now') WHERE id = ?` res, err := s.db.ExecContext(ctx, q, body, id) if err != nil { return fmt.Errorf("update draft %d: %w", id, err) } if n, _ := res.RowsAffected(); n == 0 { return fmt.Errorf("update draft %d: not found", id) } return nil } // MarkDraftSent records a successful send. func (s *Store) MarkDraftSent(ctx context.Context, id int64) error { const q = `UPDATE drafts SET status = 'sent', sent_at = datetime('now'), updated_at = datetime('now') WHERE id = ?` res, err := s.db.ExecContext(ctx, q, id) if err != nil { return fmt.Errorf("mark draft %d sent: %w", id, err) } if n, _ := res.RowsAffected(); n == 0 { return fmt.Errorf("mark draft %d sent: not found", id) } return nil } func scanDraft(row *sql.Row) (Draft, error) { var ( d Draft sentAt string created string updated string ) if err := row.Scan(&d.ID, &d.MessageID, &d.Body, &d.TemplateID, &d.Status, &sentAt, &created, &updated); err != nil { return Draft{}, err } d.SentAt = parseSQLTime(sentAt) d.CreatedAt = parseSQLTime(created) d.UpdatedAt = parseSQLTime(updated) return d, nil }