Owner notices: the stall, one webhook path from env to Discord (#171)

Rollout note: the owner_notices table starts empty, so the first pass after deploy sends for conditions already true — correct per one-row-per-episode; say so rather than have it reported as a bug. Prod step: create the webhook, set DISCORD_WEBHOOK_URL on the deployment, redeploy — unset is silent by design, and without that step the feature ships dark. Security invariants preserved: the webhook address is a secret in the class of TOKEN_KEY (never logged, never on a config-printing line), and the owner gate is unchanged.
This commit is contained in:
2026-08-23 00:06:39 +07:00
parent 5a32943528
commit 7b22460f5e
13 changed files with 785 additions and 18 deletions
+40
View File
@@ -1329,6 +1329,46 @@ func (s *Store) ClearSeriesFailure(site, seriesID string) error {
return nil
}
// NoticeSent reports whether the owner has already been told about this
// episode (issue #171). The row's presence is the whole state, so a missing
// row reads false without error.
func (s *Store) NoticeSent(condition, site string) (bool, error) {
var at int64
err := s.db.QueryRow(
`SELECT notified_at FROM owner_notices WHERE condition = $1 AND site = $2`,
condition, site).Scan(&at)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
return false, fmt.Errorf("notice sent %s/%s: %w", condition, site, err)
}
return true, nil
}
// MarkNoticeSent records that the owner was told. Called only after a
// successful send. Re-marking the same episode just moves the stamp: the row
// is a lock against a second message, not a log.
func (s *Store) MarkNoticeSent(condition, site string, at int64) error {
if _, err := s.db.Exec(`
INSERT INTO owner_notices (condition, site, notified_at) VALUES ($1, $2, $3)
ON CONFLICT (condition, site) DO UPDATE SET notified_at = EXCLUDED.notified_at`,
condition, site, at); err != nil {
return fmt.Errorf("mark notice sent %s/%s: %w", condition, site, err)
}
return nil
}
// ClearNotice forgets an episode, so the condition's next occurrence sends
// again. A row that is not there is not an error.
func (s *Store) ClearNotice(condition, site string) error {
if _, err := s.db.Exec(
`DELETE FROM owner_notices WHERE condition = $1 AND site = $2`, condition, site); err != nil {
return fmt.Errorf("clear notice %s/%s: %w", condition, site, err)
}
return nil
}
// DueForLatestCheck returns one Site's series whose server-side
// latest-chapter check has aged past cutoffMs, ordered by how many bookmarks
// reference them (descending) then least-recently-checked first. One Site per