package database import ( "errors" "fmt" sqlite3 "github.com/mattn/go-sqlite3" ) // MarkNotificationSent records that a notification has been sent for the given RGID. // // Uses INSERT OR IGNORE so a pre-existing marker for the same RGID (a // same-second re-notify colliding on the (rgid, sent_at) primary key) is a // no-op rather than an error: the marker's presence, not its exact timestamp, // is what matters for idempotency. // // A concurrent re-sync that prunes the external_releases row before this insert // would violate the FK constraint. OR IGNORE does NOT downgrade FK violations // in this SQLite build, so the FK error is caught explicitly and treated as a // benign no-op ("the release is already gone"). This ensures a single vanished // release cannot abort a whole digest's mark-sent loop and trigger duplicate // notifications on the next run. func MarkNotificationSent(db *DB, rgid string) error { _, err := db.Conn().Exec( "INSERT OR IGNORE INTO notifications_sent (rgid) VALUES (?)", rgid, ) if err != nil { var sqliteErr sqlite3.Error if errors.As(err, &sqliteErr) && sqliteErr.Code == sqlite3.ErrConstraint && sqliteErr.ExtendedCode == sqlite3.ErrConstraintForeignKey { // Release row was pruned concurrently; nothing to mark. return nil } return fmt.Errorf("mark notification sent: %w", err) } return nil } // IsNotificationSent checks whether a notification has already been sent for the given RGID. func IsNotificationSent(db *DB, rgid string) (bool, error) { var count int err := db.Conn().QueryRow( "SELECT COUNT(*) FROM notifications_sent WHERE rgid = ?", rgid, ).Scan(&count) if err != nil { return false, fmt.Errorf("check notification sent: %w", err) } return count > 0, nil } // GetUnnotifiedReleases returns all external_release rows for monitored artists // that have no entry in notifications_sent. Releases belonging to unmonitored // artists are excluded so the digest honors the monitoring contract. func GetUnnotifiedReleases(db *DB) ([]ExternalRelease, error) { rows, err := db.Conn().Query(` SELECT e.rgid, e.artist_id, e.title, COALESCE(e.type,''), COALESCE(e.release_date,''), e.is_ignored FROM external_releases e JOIN artist_settings s ON e.artist_id = s.id LEFT JOIN notifications_sent n ON e.rgid = n.rgid WHERE s.monitored = 1 AND n.rgid IS NULL AND e.is_ignored = 0 `) if err != nil { return nil, fmt.Errorf("query unnotified releases: %w", err) } defer rows.Close() var results []ExternalRelease for rows.Next() { var r ExternalRelease if err := rows.Scan(&r.RGID, &r.ArtistID, &r.Title, &r.Type, &r.ReleaseDate, &r.IsIgnored); err != nil { return nil, fmt.Errorf("scan unnotified release: %w", err) } results = append(results, r) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate unnotified releases: %w", err) } return results, nil } // GetNotificationSentAt returns the sent_at time for a given RGID. // Returns sql.ErrNoRows if no notification has been sent. func GetNotificationSentAt(db *DB, rgid string) (string, error) { var sentAt string err := db.Conn().QueryRow( "SELECT sent_at FROM notifications_sent WHERE rgid = ? ORDER BY sent_at DESC LIMIT 1", rgid, ).Scan(&sentAt) if err != nil { return "", err } return sentAt, nil }