Owner Finish control on the Series detail page: confirm-gated finish, instant un-finish (#158)

This commit is contained in:
2026-08-22 17:06:45 +07:00
parent 77e3f710ec
commit 948ecae2cc
10 changed files with 390 additions and 7 deletions
+6 -4
View File
@@ -42,7 +42,7 @@ type SeriesFilter struct {
// and only the anonymous boolean in raisedByReaderAnswer crosses it.
const adminSeriesColumns = `s.site, s.series_id, s.title, s.series_url, s.cover_address,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at, s.force_poll_at,
s.latest_corrected_at`
s.latest_corrected_at, s.finished_at`
// raisedByReaderAnswer answers "did a Reader's report set this number" without
// naming which Reader. Kept apart from adminSeriesColumns so the column list —
@@ -80,7 +80,9 @@ type AdminSeries struct {
LatestCorrectedAt int64
ReaderCount int
RaisedByReader bool // a Reader's report set LatestChapterNum
// FinishedAt is the owner's finish stamp: unix ms, zero while the Series is
// not finished — the same shape as the Correction stamp, and its own undo.
FinishedAt int64
}
// SeriesPage is one page of the owner's filtered Series list plus the count
@@ -196,7 +198,7 @@ func (s *Store) SeriesPage(f SeriesFilter) (SeriesPage, error) {
`+where+`
GROUP BY s.site, s.series_id, s.title, s.series_url, s.cover_address,
s.kind, s.latest_chapter, s.latest_chapter_num, s.latest_checked_at,
s.force_poll_at, s.latest_corrected_at, s.latest_raised_by
s.force_poll_at, s.latest_corrected_at, s.finished_at, s.latest_raised_by
`+having+`
ORDER BY s.latest_checked_at, s.site, s.series_id
LIMIT $`+strconv.Itoa(base+1)+` OFFSET $`+strconv.Itoa(base+2), args...)
@@ -271,7 +273,7 @@ func scanAdminSeries(scan func(...any) error) (AdminSeries, int, error) {
if err := scan(
&a.Site, &a.SeriesID, &a.Title, &a.SeriesURL, &a.CoverAddress,
&a.Kind, &a.LatestChapter, &latestChapterNum, &a.LatestCheckedAt,
&a.ForcePollAt, &a.LatestCorrectedAt,
&a.ForcePollAt, &a.LatestCorrectedAt, &a.FinishedAt,
&a.RaisedByReader, &a.ReaderCount, &total,
); err != nil {
return AdminSeries{}, 0, err
+54
View File
@@ -394,3 +394,57 @@ func TestAdminSeriesCarriesForcePollAt(t *testing.T) {
t.Fatalf("row = %+v, want ForcePollAt 5000", page.Rows)
}
}
// SetSeriesFinished is the owner's finish stamp write: finishing writes the
// given ms, un-finishing writes zero — the one undo, the same shape as the
// correction stamp. Touching a missing series is not an error: the row may
// have been orphaned, and the caller's read decides what exists.
func TestSetSeriesFinishedStampsAndClears(t *testing.T) {
s := newTestStore(t)
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 9000, bookmarks: 1})
if err := s.SetSeriesFinished("asura", "x", 42); err != nil {
t.Fatalf("SetSeriesFinished: %v", err)
}
var got int64
if err := s.db.QueryRow(
`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil {
t.Fatalf("read finished_at: %v", err)
}
if got != 42 {
t.Fatalf("finished_at = %d, want 42", got)
}
if err := s.SetSeriesFinished("asura", "x", 0); err != nil {
t.Fatalf("SetSeriesFinished un-finish: %v", err)
}
if err := s.db.QueryRow(
`SELECT finished_at FROM series WHERE site = 'asura' AND series_id = 'x'`).Scan(&got); err != nil {
t.Fatalf("read finished_at after un-finish: %v", err)
}
if got != 0 {
t.Fatalf("finished_at = %d, want 0 (un-finish writes zero)", got)
}
if err := s.SetSeriesFinished("asura", "ghost", 42); err != nil {
t.Fatalf("SetSeriesFinished missing: %v", err)
}
}
// The admin projection carries the finish stamp so the web layer can render
// the finished state without a second read.
func TestAdminSeriesCarriesFinishedAt(t *testing.T) {
s := newTestStore(t)
seedAdminSeries(t, s, seriesSeed{key: "asura:x", url: "u", checkedAt: 1000, bookmarks: 1})
if err := s.SetSeriesFinished("asura", "x", 5000); err != nil {
t.Fatalf("SetSeriesFinished: %v", err)
}
page, err := s.SeriesPage(SeriesFilter{})
if err != nil {
t.Fatalf("SeriesPage: %v", err)
}
if len(page.Rows) != 1 || page.Rows[0].FinishedAt != 5000 {
t.Fatalf("row = %+v, want FinishedAt 5000", page.Rows)
}
}
+15
View File
@@ -1440,6 +1440,21 @@ func (s *Store) ForceSeriesPoll(site, seriesID string, at int64) error {
return nil
}
// SetSeriesFinished stamps or clears the owner's finish. at is unix ms to
// finish, zero to un-finish. A finished Series drops out of the Lane's reads
// (issue #157), and nothing else writes this column: it is the only writer
// outside migration 0016, so a machine write can never retire a Series
// silently. Touching a missing series is not an error: the row may have been
// orphaned, and the caller's read decides what exists.
func (s *Store) SetSeriesFinished(site, seriesID string, at int64) error {
if _, err := s.db.Exec(
`UPDATE series SET finished_at = $1 WHERE site = $2 AND series_id = $3`,
at, site, seriesID); err != nil {
return fmt.Errorf("set series finished %s:%s: %w", site, seriesID, err)
}
return nil
}
// LatestCheckedAt reads the column MarkLatestChecked writes. It exists for
// tests outside this package (the poller's own tests assert on rest
// bookkeeping) — see MarkLatestChecked for why the field stays off the