([])
const [carouselSaving, setCarouselSaving] = useState(false)
@@ -116,13 +164,15 @@ export default function SettingsPage() {
try {
const res = await apiFetch("/v1/settings/breaking-news")
if (!res.ok) throw new Error(await readErrorMessage(res, `Could not load breaking news settings (${res.status})`))
- const body = (await res.json()) as { enabled?: boolean; text?: string }
- const enabled = Boolean(body.enabled)
- const text = String(body.text ?? "")
+ const body = (await res.json()) as BreakingNewsResponse
+ const state = readBreakingNews(body)
if (!cancelled) {
- setBreakingEnabled(enabled)
- setBreakingText(text)
- setBreakingSaved({ enabled, text })
+ setBreakingEnabled(state.manual.enabled)
+ setBreakingText(state.manual.text)
+ setBreakingWindowOn(state.window !== "0")
+ setBreakingWindow(state.window === "0" ? "24" : state.window)
+ setBreakingSaved({ enabled: state.manual.enabled, text: state.manual.text, window: state.window })
+ setBreakingLive(state.live)
}
} catch (err) {
if (!cancelled) {
@@ -166,6 +216,15 @@ export default function SettingsPage() {
setBreakingMessage("Banner text is required when the banner is enabled")
return
}
+ // 0 is what the API takes for "no limit", which is the unticked state.
+ const windowHours = breakingWindowOn ? Number(breakingWindow) : 0
+ if (
+ breakingWindowOn &&
+ (!Number.isInteger(windowHours) || windowHours < 1 || windowHours > MAX_BREAKING_WINDOW_HOURS)
+ ) {
+ setBreakingMessage(`Banner duration must be a whole number of hours between 1 and ${MAX_BREAKING_WINDOW_HOURS}`)
+ return
+ }
setBreakingSaving(true)
setBreakingMessage(null)
@@ -173,15 +232,17 @@ export default function SettingsPage() {
const res = await apiFetch("/v1/settings/breaking-news", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ enabled: breakingEnabled, text }),
+ body: JSON.stringify({ enabled: breakingEnabled, text, window_hours: windowHours }),
})
if (!res.ok) throw new Error(await readErrorMessage(res, `Could not save breaking news settings (${res.status})`))
- const body = (await res.json()) as { enabled?: boolean; text?: string }
- const savedEnabled = Boolean(body.enabled)
- const savedText = String(body.text ?? "")
- setBreakingEnabled(savedEnabled)
- setBreakingText(savedText)
- setBreakingSaved({ enabled: savedEnabled, text: savedText })
+ const body = (await res.json()) as BreakingNewsResponse
+ const state = readBreakingNews(body)
+ setBreakingEnabled(state.manual.enabled)
+ setBreakingText(state.manual.text)
+ setBreakingWindowOn(state.window !== "0")
+ setBreakingWindow(state.window === "0" ? "24" : state.window)
+ setBreakingSaved({ enabled: state.manual.enabled, text: state.manual.text, window: state.window })
+ setBreakingLive(state.live)
setBreakingMessage("Saved")
} catch (err) {
setBreakingMessage(err instanceof Error ? err.message : "Could not save breaking news settings.")
@@ -366,7 +427,11 @@ export default function SettingsPage() {
const carouselDirty = JSON.stringify(carouselSlides) !== JSON.stringify(carouselSaved)
const siteTitleDirty = siteTitleDraft.trim() !== siteTitle
- const breakingDirty = breakingEnabled !== breakingSaved.enabled || breakingText.trim() !== breakingSaved.text
+ const breakingWindowValue = breakingWindowOn ? breakingWindow.trim() : "0"
+ const breakingDirty =
+ breakingEnabled !== breakingSaved.enabled ||
+ breakingText.trim() !== breakingSaved.text ||
+ breakingWindowValue !== breakingSaved.window
return (
@@ -628,9 +693,28 @@ export default function SettingsPage() {
title="Breaking News"
storageKey="breaking-news"
dirty={breakingDirty}
- summary={breakingEnabled ? "Enabled" : "Off"}
- description="Show a breaking-news banner across the top of the public homepage."
+ summary={
+ breakingLive.source === "article" ? "Live from an article" : breakingLive.source === "manual" ? "Enabled" : "Off"
+ }
+ description="Show a breaking-news banner across the top of the public homepage. Any editor can raise one by ticking Breaking news on their article; it appears when the article publishes and comes down when the flag does."
>
+ {breakingLive.source === "article" && (
+
+
An article is driving the banner: “{breakingLive.text}”
+
+ It overrides the banner below until it is unflagged or its duration runs out. To take it down now, untick
+ “Breaking news” on{" "}
+ {breakingLive.articleSlug ? (
+
+ that article
+
+ ) : (
+ "that article"
+ )}
+ .
+
+
+ )}
+
diff --git a/server/docs/docs.go b/server/docs/docs.go
index abf6f4e..be5b0cc 100644
--- a/server/docs/docs.go
+++ b/server/docs/docs.go
@@ -4984,6 +4984,9 @@ const docTemplate = `{
"models.BreakingNewsSettings": {
"type": "object",
"properties": {
+ "article_slug": {
+ "type": "string"
+ },
"enabled": {
"type": "boolean"
},
@@ -5000,17 +5003,36 @@ const docTemplate = `{
},
"text": {
"type": "string"
+ },
+ "window_hours": {
+ "type": "integer"
}
}
},
"models.BreakingNewsSettingsResponse": {
"type": "object",
"properties": {
+ "article_slug": {
+ "type": "string"
+ },
+ "article_title": {
+ "type": "string"
+ },
"enabled": {
"type": "boolean"
},
+ "manual": {
+ "$ref": "#/definitions/models.BreakingNewsSettings"
+ },
+ "source": {
+ "type": "string"
+ },
"text": {
"type": "string"
+ },
+ "window_hours": {
+ "description": "WindowHours is 0 when a flagged article holds the banner indefinitely,\nwhich is the default; an admin sets a limit in Settings to opt in.",
+ "type": "integer"
}
}
},
diff --git a/server/docs/swagger.json b/server/docs/swagger.json
index 6acbbd8..4c26911 100644
--- a/server/docs/swagger.json
+++ b/server/docs/swagger.json
@@ -4981,6 +4981,9 @@
"models.BreakingNewsSettings": {
"type": "object",
"properties": {
+ "article_slug": {
+ "type": "string"
+ },
"enabled": {
"type": "boolean"
},
@@ -4997,17 +5000,36 @@
},
"text": {
"type": "string"
+ },
+ "window_hours": {
+ "type": "integer"
}
}
},
"models.BreakingNewsSettingsResponse": {
"type": "object",
"properties": {
+ "article_slug": {
+ "type": "string"
+ },
+ "article_title": {
+ "type": "string"
+ },
"enabled": {
"type": "boolean"
},
+ "manual": {
+ "$ref": "#/definitions/models.BreakingNewsSettings"
+ },
+ "source": {
+ "type": "string"
+ },
"text": {
"type": "string"
+ },
+ "window_hours": {
+ "description": "WindowHours is 0 when a flagged article holds the banner indefinitely,\nwhich is the default; an admin sets a limit in Settings to opt in.",
+ "type": "integer"
}
}
},
diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml
index 1993529..cadfcb0 100644
--- a/server/docs/swagger.yaml
+++ b/server/docs/swagger.yaml
@@ -477,6 +477,8 @@ definitions:
type: object
models.BreakingNewsSettings:
properties:
+ article_slug:
+ type: string
enabled:
type: boolean
text:
@@ -488,13 +490,28 @@ definitions:
type: boolean
text:
type: string
+ window_hours:
+ type: integer
type: object
models.BreakingNewsSettingsResponse:
properties:
+ article_slug:
+ type: string
+ article_title:
+ type: string
enabled:
type: boolean
+ manual:
+ $ref: '#/definitions/models.BreakingNewsSettings'
+ source:
+ type: string
text:
type: string
+ window_hours:
+ description: |-
+ WindowHours is 0 when a flagged article holds the banner indefinitely,
+ which is the default; an admin sets a limit in Settings to opt in.
+ type: integer
type: object
models.CategorySummary:
properties:
diff --git a/server/internal/database/breaking_news_integration_test.go b/server/internal/database/breaking_news_integration_test.go
new file mode 100644
index 0000000..9e7077d
--- /dev/null
+++ b/server/internal/database/breaking_news_integration_test.go
@@ -0,0 +1,272 @@
+package database
+
+import (
+ "context"
+ "database/sql"
+ "os"
+ "testing"
+
+ "server/internal/models"
+
+ _ "github.com/go-sql-driver/mysql"
+)
+
+// The banner is resolved entirely in SQL -- the published predicate, the
+// window, and "newest wins" are all in one query -- so these need a real
+// MariaDB. The interesting case is the scheduled one: an article whose
+// pub_date is in the future must not raise the banner early, and UTC_TIMESTAMP
+// comparisons against DATETIME columns are exactly what a fake would get wrong.
+//
+// CMS_TEST_DSN='user:pw@tcp(127.0.0.1:3306)/cms_test?parseTime=true&multiStatements=true' go test ./internal/database/ -run BreakingNewsState -v
+func breakingNewsTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+
+ dsn := os.Getenv("CMS_TEST_DSN")
+ if dsn == "" {
+ t.Skip("CMS_TEST_DSN not set; skipping breaking-news integration test")
+ }
+
+ conn, err := sql.Open("mysql", dsn)
+ if err != nil {
+ t.Fatalf("open test database: %v", err)
+ }
+ conn.SetMaxOpenConns(1)
+ if err := conn.Ping(); err != nil {
+ t.Fatalf("ping test database: %v", err)
+ }
+
+ ctx := context.Background()
+ var acquired sql.NullInt64
+ if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 60)", "cms_integration_test_shared_tables").Scan(&acquired); err != nil {
+ t.Fatalf("acquire test lock: %v", err)
+ }
+ if !acquired.Valid || acquired.Int64 != 1 {
+ t.Fatal("timed out waiting for the shared table lock")
+ }
+ t.Cleanup(func() {
+ _, _ = conn.ExecContext(context.Background(), "SELECT RELEASE_LOCK(?)", "cms_integration_test_shared_tables")
+ conn.Close()
+ })
+
+ for _, stmt := range []string{
+ "DROP TABLE IF EXISTS articles",
+ "DROP TABLE IF EXISTS cms_settings",
+ } {
+ if _, err := conn.ExecContext(ctx, stmt); err != nil {
+ t.Fatalf("reset schema (%s): %v", stmt, err)
+ }
+ }
+
+ if _, err := conn.ExecContext(ctx, `
+ CREATE TABLE articles (
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ title LONGTEXT,
+ slug VARCHAR(255) NOT NULL UNIQUE,
+ breaking_news BOOL,
+ pub_date DATETIME NULL,
+ scheduled_pub_date DATETIME NULL,
+ archived_at DATETIME NULL
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+ `); err != nil {
+ t.Fatalf("create articles table: %v", err)
+ }
+ if err := EnsureSettingsTable(ctx, conn); err != nil {
+ t.Fatalf("ensure settings table: %v", err)
+ }
+ if err := EnsureArticlesBreakingNewsIndex(ctx, conn); err != nil {
+ t.Fatalf("ensure breaking-news index: %v", err)
+ }
+
+ // The settings cache is per-process and outlives the table drop above, so
+ // a value written by an earlier test would be served here as if it were
+ // still stored. Clearing it makes each case start from real state.
+ ResetSettingsCache()
+
+ return conn
+}
+
+// insertArticle adds one article. pubExpr and schedExpr are SQL expressions so
+// a case can say "an hour ago" or "in an hour" relative to the server clock
+// rather than the test's, which is the comparison the query actually makes.
+func insertArticle(t *testing.T, conn *sql.DB, slug, title string, breaking bool, pubExpr, schedExpr string) {
+ t.Helper()
+ flag := 0
+ if breaking {
+ flag = 1
+ }
+ _, err := conn.ExecContext(context.Background(),
+ "INSERT INTO articles (slug, title, breaking_news, pub_date, scheduled_pub_date) VALUES (?, ?, ?, "+pubExpr+", "+schedExpr+")",
+ slug, title, flag)
+ if err != nil {
+ t.Fatalf("insert article %s: %v", slug, err)
+ }
+}
+
+func TestBreakingNewsState_FallsBackToTheManualBanner(t *testing.T) {
+ conn := breakingNewsTestDB(t)
+ ctx := context.Background()
+
+ if err := SetBreakingNews(ctx, conn, models.BreakingNewsSettings{Enabled: true, Text: "Campus closed"}, breakingNewsWindowUnlimited); err != nil {
+ t.Fatalf("set manual banner: %v", err)
+ }
+
+ state, err := GetBreakingNewsState(ctx, conn)
+ if err != nil {
+ t.Fatalf("get state: %v", err)
+ }
+ if !state.Enabled || state.Text != "Campus closed" {
+ t.Errorf("expected the manual banner, got %+v", state.BreakingNewsSettings)
+ }
+ if state.Source != models.BreakingNewsSourceManual {
+ t.Errorf("source = %q, want %q", state.Source, models.BreakingNewsSourceManual)
+ }
+ if state.WindowHours != breakingNewsWindowUnlimited {
+ t.Errorf("window_hours = %d, want no limit", state.WindowHours)
+ }
+}
+
+func TestBreakingNewsState_PublishedArticleOverridesTheManualBanner(t *testing.T) {
+ conn := breakingNewsTestDB(t)
+ ctx := context.Background()
+
+ if err := SetBreakingNews(ctx, conn, models.BreakingNewsSettings{Enabled: true, Text: "Campus closed"}, breakingNewsWindowUnlimited); err != nil {
+ t.Fatalf("set manual banner: %v", err)
+ }
+ insertArticle(t, conn, "dragonfly", "Dragonfly headliner", true, "UTC_TIMESTAMP() - INTERVAL 10 MINUTE", "NULL")
+
+ state, err := GetBreakingNewsState(ctx, conn)
+ if err != nil {
+ t.Fatalf("get state: %v", err)
+ }
+ if !state.Enabled || state.Text != "Dragonfly headliner" {
+ t.Errorf("expected the article to drive the banner, got %+v", state.BreakingNewsSettings)
+ }
+ if state.Source != models.BreakingNewsSourceArticle {
+ t.Errorf("source = %q, want %q", state.Source, models.BreakingNewsSourceArticle)
+ }
+ // The slug rides on the banner itself, not just the settings view: it is
+ // what the public site builds the banner's link from.
+ if state.BreakingNewsSettings.ArticleSlug != "dragonfly" {
+ t.Errorf("article_slug = %q, want %q", state.BreakingNewsSettings.ArticleSlug, "dragonfly")
+ }
+ // The manual banner is preserved so the settings screen can still edit it.
+ if !state.Manual.Enabled || state.Manual.Text != "Campus closed" {
+ t.Errorf("manual banner was not preserved, got %+v", state.Manual)
+ }
+ // A hand-typed banner has no article behind it, so it must not inherit the
+ // overriding article's link.
+ if state.Manual.ArticleSlug != "" {
+ t.Errorf("manual banner carried a slug: %q", state.Manual.ArticleSlug)
+ }
+}
+
+func TestBreakingNewsState_ScheduledArticleWaitsForItsPubDate(t *testing.T) {
+ conn := breakingNewsTestDB(t)
+ ctx := context.Background()
+
+ // Exactly what an editor scheduling an 11am story leaves behind: flagged,
+ // not yet published, waiting on the scheduler tick.
+ insertArticle(t, conn, "tomorrow", "Scheduled scoop", true, "NULL", "UTC_TIMESTAMP() + INTERVAL 1 HOUR")
+
+ state, err := GetBreakingNewsState(ctx, conn)
+ if err != nil {
+ t.Fatalf("get state: %v", err)
+ }
+ if state.Enabled {
+ t.Fatalf("a scheduled article raised the banner early: %+v", state)
+ }
+
+ // Publish it the way the scheduler does, then it takes the banner with no
+ // further action -- that is the whole point of deriving it.
+ if _, err := conn.ExecContext(ctx,
+ "UPDATE articles SET pub_date = UTC_TIMESTAMP(), scheduled_pub_date = NULL WHERE slug = ?", "tomorrow"); err != nil {
+ t.Fatalf("publish article: %v", err)
+ }
+
+ state, err = GetBreakingNewsState(ctx, conn)
+ if err != nil {
+ t.Fatalf("get state after publish: %v", err)
+ }
+ if !state.Enabled || state.Text != "Scheduled scoop" {
+ t.Errorf("expected the published article to take the banner, got %+v", state.BreakingNewsSettings)
+ }
+ if state.BreakingNewsSettings.ArticleSlug != "tomorrow" {
+ t.Errorf("article_slug = %q, want %q", state.BreakingNewsSettings.ArticleSlug, "tomorrow")
+ }
+}
+
+func TestBreakingNewsState_NewestFlaggedArticleWins(t *testing.T) {
+ conn := breakingNewsTestDB(t)
+ ctx := context.Background()
+
+ insertArticle(t, conn, "older", "Older breaking story", true, "UTC_TIMESTAMP() - INTERVAL 3 HOUR", "NULL")
+ insertArticle(t, conn, "newer", "Newer breaking story", true, "UTC_TIMESTAMP() - INTERVAL 1 HOUR", "NULL")
+
+ state, err := GetBreakingNewsState(ctx, conn)
+ if err != nil {
+ t.Fatalf("get state: %v", err)
+ }
+ if state.Text != "Newer breaking story" {
+ t.Errorf("text = %q, want the newer story", state.Text)
+ }
+}
+
+// The default. An article flagged months ago and never unticked still holds the
+// banner, because nothing was configured to take it down.
+func TestBreakingNewsState_KeepsAnOldArticleWhenNoWindowIsSet(t *testing.T) {
+ conn := breakingNewsTestDB(t)
+ ctx := context.Background()
+
+ insertArticle(t, conn, "ancient", "Long-forgotten emergency", true, "UTC_TIMESTAMP() - INTERVAL 30 DAY", "NULL")
+
+ state, err := GetBreakingNewsState(ctx, conn)
+ if err != nil {
+ t.Fatalf("get state: %v", err)
+ }
+ if !state.Enabled || state.Text != "Long-forgotten emergency" {
+ t.Errorf("expected the article to still hold the banner, got %+v", state.BreakingNewsSettings)
+ }
+ if state.WindowHours != breakingNewsWindowUnlimited {
+ t.Errorf("window_hours = %d, want no limit by default", state.WindowHours)
+ }
+}
+
+func TestBreakingNewsState_IgnoresArticlesOutsideAnAdminSetWindow(t *testing.T) {
+ conn := breakingNewsTestDB(t)
+ ctx := context.Background()
+
+ if err := SetBreakingNews(ctx, conn, models.BreakingNewsSettings{}, 2); err != nil {
+ t.Fatalf("set window: %v", err)
+ }
+ insertArticle(t, conn, "stale", "Yesterday's emergency", true, "UTC_TIMESTAMP() - INTERVAL 5 HOUR", "NULL")
+
+ state, err := GetBreakingNewsState(ctx, conn)
+ if err != nil {
+ t.Fatalf("get state: %v", err)
+ }
+ if state.Enabled {
+ t.Errorf("an article past the window still held the banner: %+v", state)
+ }
+ if state.Source != models.BreakingNewsSourceNone {
+ t.Errorf("source = %q, want %q", state.Source, models.BreakingNewsSourceNone)
+ }
+}
+
+func TestBreakingNewsState_IgnoresArchivedAndUnflaggedArticles(t *testing.T) {
+ conn := breakingNewsTestDB(t)
+ ctx := context.Background()
+
+ insertArticle(t, conn, "plain", "Not breaking", false, "UTC_TIMESTAMP() - INTERVAL 5 MINUTE", "NULL")
+ insertArticle(t, conn, "pulled", "Pulled story", true, "UTC_TIMESTAMP() - INTERVAL 5 MINUTE", "NULL")
+ if _, err := conn.ExecContext(ctx, "UPDATE articles SET archived_at = UTC_TIMESTAMP() WHERE slug = ?", "pulled"); err != nil {
+ t.Fatalf("archive article: %v", err)
+ }
+
+ state, err := GetBreakingNewsState(ctx, conn)
+ if err != nil {
+ t.Fatalf("get state: %v", err)
+ }
+ if state.Enabled {
+ t.Errorf("expected no banner, got %+v", state)
+ }
+}
diff --git a/server/internal/database/breaking_news_test.go b/server/internal/database/breaking_news_test.go
new file mode 100644
index 0000000..aebd1ff
--- /dev/null
+++ b/server/internal/database/breaking_news_test.go
@@ -0,0 +1,27 @@
+package database
+
+import (
+ "testing"
+)
+
+func TestNormalizeBreakingNewsWindow(t *testing.T) {
+ cases := []struct {
+ name string
+ hours int
+ want int
+ }{
+ {"zero is no limit", 0, breakingNewsWindowUnlimited},
+ {"negative is no limit", -3, breakingNewsWindowUnlimited},
+ {"in range is kept", 6, 6},
+ {"the maximum is kept", maxBreakingNewsWindowHours, maxBreakingNewsWindowHours},
+ {"above the maximum is clamped", maxBreakingNewsWindowHours + 1, maxBreakingNewsWindowHours},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := NormalizeBreakingNewsWindow(tc.hours); got != tc.want {
+ t.Errorf("NormalizeBreakingNewsWindow(%d) = %d, want %d", tc.hours, got, tc.want)
+ }
+ })
+ }
+}
diff --git a/server/internal/database/settings.go b/server/internal/database/settings.go
index 2b522f6..dada102 100644
--- a/server/internal/database/settings.go
+++ b/server/internal/database/settings.go
@@ -3,6 +3,7 @@ package database
import (
"context"
"database/sql"
+ "strconv"
"strings"
"server/internal/models"
@@ -19,11 +20,40 @@ const (
)
// Breaking-news banner settings keys, stored in the cms_settings key-value table.
+//
+// These hold the MANUAL banner only. The banner an editor raises by flagging
+// an article is not stored anywhere: it is derived from the article on read,
+// so it appears exactly when the article publishes and disappears when the
+// flag comes off.
const (
keyBreakingNewsEnabled = "breaking_news_enabled"
keyBreakingNewsText = "breaking_news_text"
+ keyBreakingNewsWindow = "breaking_news_window_hours"
)
+// How long a flagged article keeps the banner after it publishes.
+//
+// Zero means no limit, and that is the default: the banner comes down when the
+// editor unticks the article, and nothing takes it down on their behalf. An
+// admin can set a limit in Settings if they want one, but it is opt-in --
+// a banner vanishing on a timer nobody chose would be its own surprise.
+const (
+ breakingNewsWindowUnlimited = 0
+ maxBreakingNewsWindowHours = 24 * 7
+)
+
+// NormalizeBreakingNewsWindow clamps a requested window into the supported
+// range. A zero or negative value is "no limit".
+func NormalizeBreakingNewsWindow(hours int) int {
+ if hours <= 0 {
+ return breakingNewsWindowUnlimited
+ }
+ if hours > maxBreakingNewsWindowHours {
+ return maxBreakingNewsWindowHours
+ }
+ return hours
+}
+
var seoSettingDefaults = map[string]string{
keySEOOGTitle: "The Triangle | Drexel University's Independent Student Newspaper",
keySEOOGDescription: "Award-winning independent student journalism at Drexel University since 1925.",
@@ -67,9 +97,79 @@ func SetSiteTitle(ctx context.Context, conn *sql.DB, title string) error {
return setSetting(ctx, conn, "site_title", normalized)
}
-// GetBreakingNews returns the breaking-news banner settings, defaulting to
-// disabled with empty text when unset.
+// GetBreakingNews returns the banner the public site should render.
+//
+// It is the effective half of GetBreakingNewsState -- the homepage only needs
+// to know whether to show a banner and what it says, not which of the two
+// sources produced it.
func GetBreakingNews(ctx context.Context, conn *sql.DB) (models.BreakingNewsSettings, error) {
+ state, err := GetBreakingNewsState(ctx, conn)
+ if err != nil {
+ return models.BreakingNewsSettings{}, err
+ }
+ return state.BreakingNewsSettings, nil
+}
+
+// GetBreakingNewsState resolves the banner from both of its sources.
+//
+// A published article flagged breaking wins over the manual banner: it is the
+// more specific and more recent signal, and the newsroom flow the flag exists
+// for is "this story is the breaking story now". When two are live the newest
+// one holds the banner, since there is only one banner to hold.
+func GetBreakingNewsState(ctx context.Context, conn *sql.DB) (models.BreakingNewsState, error) {
+ manual, err := getManualBreakingNews(ctx, conn)
+ if err != nil {
+ return models.BreakingNewsState{}, err
+ }
+
+ window, err := GetBreakingNewsWindow(ctx, conn)
+ if err != nil {
+ return models.BreakingNewsState{}, err
+ }
+
+ state := models.BreakingNewsState{
+ BreakingNewsSettings: manual,
+ Source: models.BreakingNewsSourceManual,
+ Manual: manual,
+ WindowHours: window,
+ }
+ if !manual.Enabled {
+ state.Source = models.BreakingNewsSourceNone
+ }
+
+ slug, title, err := latestBreakingArticle(ctx, conn, window)
+ if err != nil {
+ return models.BreakingNewsState{}, err
+ }
+ if title != "" {
+ state.BreakingNewsSettings = models.BreakingNewsSettings{
+ Enabled: true,
+ Text: title,
+ ArticleSlug: slug,
+ }
+ state.Source = models.BreakingNewsSourceArticle
+ state.ArticleTitle = title
+ }
+
+ return state, nil
+}
+
+// GetBreakingNewsWindow returns the configured banner window in hours.
+func GetBreakingNewsWindow(ctx context.Context, conn *sql.DB) (int, error) {
+ raw, err := getSetting(ctx, conn, keyBreakingNewsWindow, "")
+ if err != nil {
+ return 0, err
+ }
+ hours, convErr := strconv.Atoi(strings.TrimSpace(raw))
+ if convErr != nil {
+ // A blank or corrupt value is a missing setting, not a reason to fail
+ // the homepage; unset is the documented default anyway.
+ return breakingNewsWindowUnlimited, nil
+ }
+ return NormalizeBreakingNewsWindow(hours), nil
+}
+
+func getManualBreakingNews(ctx context.Context, conn *sql.DB) (models.BreakingNewsSettings, error) {
enabled, err := getSetting(ctx, conn, keyBreakingNewsEnabled, "false")
if err != nil {
return models.BreakingNewsSettings{}, err
@@ -86,8 +186,46 @@ func GetBreakingNews(ctx context.Context, conn *sql.DB) (models.BreakingNewsSett
}, nil
}
-// SetBreakingNews persists the breaking-news banner settings.
-func SetBreakingNews(ctx context.Context, conn *sql.DB, s models.BreakingNewsSettings) error {
+// latestBreakingArticle returns the newest published article flagged breaking,
+// or empty strings when none is.
+//
+// The published predicate is the same one every other public read uses, which
+// is the whole point: a scheduled article starts driving the banner at the
+// same instant it starts being readable, with no separate publish hook to
+// drift out of step with it.
+//
+// windowHours of 0 is no limit, in which case the age clause is left off the
+// query rather than passed as a sentinel -- `INTERVAL 0 HOUR` would exclude
+// every article instead of including them all.
+func latestBreakingArticle(ctx context.Context, conn *sql.DB, windowHours int) (string, string, error) {
+ query := `
+ SELECT slug, title
+ FROM articles
+ WHERE breaking_news = 1
+ AND pub_date IS NOT NULL
+ AND pub_date <= UTC_TIMESTAMP()
+ AND archived_at IS NULL
+ `
+ args := []any{}
+ if windowHours > 0 {
+ query += " AND pub_date > UTC_TIMESTAMP() - INTERVAL ? HOUR\n"
+ args = append(args, windowHours)
+ }
+ query += "\tORDER BY pub_date DESC, id DESC\n\tLIMIT 1"
+
+ var slug, title sql.NullString
+ err := conn.QueryRowContext(ctx, query, args...).Scan(&slug, &title)
+ if err == sql.ErrNoRows {
+ return "", "", nil
+ }
+ if err != nil {
+ return "", "", err
+ }
+ return strings.TrimSpace(slug.String), strings.TrimSpace(title.String), nil
+}
+
+// SetBreakingNews persists the manual breaking-news banner and its window.
+func SetBreakingNews(ctx context.Context, conn *sql.DB, s models.BreakingNewsSettings, windowHours int) error {
enabled := "false"
if s.Enabled {
enabled = "true"
@@ -95,7 +233,10 @@ func SetBreakingNews(ctx context.Context, conn *sql.DB, s models.BreakingNewsSet
if err := setSetting(ctx, conn, keyBreakingNewsEnabled, enabled); err != nil {
return err
}
- return setSetting(ctx, conn, keyBreakingNewsText, strings.TrimSpace(s.Text))
+ if err := setSetting(ctx, conn, keyBreakingNewsText, strings.TrimSpace(s.Text)); err != nil {
+ return err
+ }
+ return setSetting(ctx, conn, keyBreakingNewsWindow, strconv.Itoa(NormalizeBreakingNewsWindow(windowHours)))
}
// getSetting reads a single cms_settings value, returning fallback when the key
diff --git a/server/internal/database/users.go b/server/internal/database/users.go
index 8550378..900b485 100644
--- a/server/internal/database/users.go
+++ b/server/internal/database/users.go
@@ -45,6 +45,25 @@ func EnsureArticlesPublishedIndex(ctx context.Context, conn *sql.DB) error {
return err
}
+// EnsureArticlesBreakingNewsIndex indexes the banner lookup.
+//
+// GetBreakingNewsState runs on every homepage render, and its predicate is
+// `breaking_news = 1` over the whole migrated corpus -- tens of thousands of
+// rows scanned to find the handful ever flagged, or none at all, which is the
+// common case. Leading on the flag lets the optimizer skip straight to those
+// rows, and pub_date riding along orders them inside the index so the LIMIT 1
+// needs no sort.
+//
+// Non-fatal like the other index builders: the banner resolves correctly
+// without it, just by reading more of the table to do it.
+func EnsureArticlesBreakingNewsIndex(ctx context.Context, conn *sql.DB) error {
+ _, err := conn.ExecContext(ctx, `
+ ALTER TABLE articles
+ ADD INDEX IF NOT EXISTS idx_articles_breaking_news (`+"`breaking_news`, `pub_date`"+`)
+ `)
+ return err
+}
+
// EnsureArticlesSlugIndex indexes the column every article is addressed by:
// the detail endpoint, the comment thread, the permalink check, the
// featured-article write. Without it each of those is a full scan of the
diff --git a/server/internal/handlers/breaking_news_settings_integration_test.go b/server/internal/handlers/breaking_news_settings_integration_test.go
new file mode 100644
index 0000000..21d3889
--- /dev/null
+++ b/server/internal/handlers/breaking_news_settings_integration_test.go
@@ -0,0 +1,178 @@
+package handlers
+
+import (
+ "context"
+ "database/sql"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+
+ db "server/internal/database"
+
+ _ "github.com/go-sql-driver/mysql"
+)
+
+// CMS_TEST_DSN='user:pw@tcp(127.0.0.1:3306)/cms_test?parseTime=true&multiStatements=true' go test ./internal/handlers/ -run BreakingNewsSettings -v
+func breakingNewsSettingsTestDB(t *testing.T) *sql.DB {
+ t.Helper()
+
+ dsn := os.Getenv("CMS_TEST_DSN")
+ if dsn == "" {
+ t.Skip("CMS_TEST_DSN not set; skipping breaking-news settings integration test")
+ }
+
+ conn, err := sql.Open("mysql", dsn)
+ if err != nil {
+ t.Fatalf("open test database: %v", err)
+ }
+ conn.SetMaxOpenConns(1)
+ if err := conn.Ping(); err != nil {
+ t.Fatalf("ping test database: %v", err)
+ }
+
+ ctx := context.Background()
+ var acquired sql.NullInt64
+ if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, 60)", "cms_integration_test_shared_tables").Scan(&acquired); err != nil {
+ t.Fatalf("acquire test lock: %v", err)
+ }
+ if !acquired.Valid || acquired.Int64 != 1 {
+ t.Fatal("timed out waiting for the shared table lock")
+ }
+ t.Cleanup(func() {
+ _, _ = conn.ExecContext(context.Background(), "SELECT RELEASE_LOCK(?)", "cms_integration_test_shared_tables")
+ conn.Close()
+ })
+
+ for _, stmt := range []string{
+ "DROP TABLE IF EXISTS articles",
+ "DROP TABLE IF EXISTS cms_settings",
+ } {
+ if _, err := conn.ExecContext(ctx, stmt); err != nil {
+ t.Fatalf("reset schema (%s): %v", stmt, err)
+ }
+ }
+ if _, err := conn.ExecContext(ctx, `
+ CREATE TABLE articles (
+ id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
+ title LONGTEXT,
+ slug VARCHAR(255) NOT NULL UNIQUE,
+ breaking_news BOOL,
+ pub_date DATETIME NULL,
+ archived_at DATETIME NULL
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+ `); err != nil {
+ t.Fatalf("create articles table: %v", err)
+ }
+ if err := db.EnsureSettingsTable(ctx, conn); err != nil {
+ t.Fatalf("ensure settings table: %v", err)
+ }
+ db.ResetSettingsCache()
+
+ return conn
+}
+
+func patchBreakingNews(t *testing.T, conn *sql.DB, body string) *httptest.ResponseRecorder {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPatch, "/v1/settings/breaking-news", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ PatchBreakingNews(conn).ServeHTTP(rec, req)
+ db.ResetSettingsCache()
+ return rec
+}
+
+func decodeBreakingNews(t *testing.T, rec *httptest.ResponseRecorder) map[string]any {
+ t.Helper()
+ var payload map[string]any
+ if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
+ t.Fatalf("decode response %q: %v", rec.Body.String(), err)
+ }
+ return payload
+}
+
+// window_hours is optional, and the settings screen is not the only thing that
+// can toggle the banner. A plain enable/disable must not quietly reset how long
+// flagged articles hold the homepage.
+func TestBreakingNewsSettingsHTTP_OmittedWindowIsPreserved(t *testing.T) {
+ conn := breakingNewsSettingsTestDB(t)
+
+ if rec := patchBreakingNews(t, conn, `{"enabled":true,"text":"Campus closed","window_hours":6}`); rec.Code != http.StatusOK {
+ t.Fatalf("set window: status %d, body %s", rec.Code, rec.Body.String())
+ }
+
+ rec := patchBreakingNews(t, conn, `{"enabled":false,"text":""}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("disable banner: status %d, body %s", rec.Code, rec.Body.String())
+ }
+ if got := decodeBreakingNews(t, rec)["window_hours"]; got != float64(6) {
+ t.Errorf("window_hours = %v, want 6", got)
+ }
+}
+
+// 0 is how an admin turns the limit back off, so it has to round-trip rather
+// than fall back to some built-in duration.
+func TestBreakingNewsSettingsHTTP_ZeroWindowClearsTheLimit(t *testing.T) {
+ conn := breakingNewsSettingsTestDB(t)
+
+ if rec := patchBreakingNews(t, conn, `{"enabled":false,"text":"","window_hours":6}`); rec.Code != http.StatusOK {
+ t.Fatalf("set window: status %d, body %s", rec.Code, rec.Body.String())
+ }
+
+ rec := patchBreakingNews(t, conn, `{"enabled":false,"text":"","window_hours":0}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("clear window: status %d, body %s", rec.Code, rec.Body.String())
+ }
+ if got := decodeBreakingNews(t, rec)["window_hours"]; got != float64(0) {
+ t.Errorf("window_hours = %v, want 0", got)
+ }
+}
+
+func TestBreakingNewsSettingsHTTP_RejectsANegativeWindow(t *testing.T) {
+ conn := breakingNewsSettingsTestDB(t)
+
+ rec := patchBreakingNews(t, conn, `{"enabled":false,"text":"","window_hours":-1}`)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400 (body %s)", rec.Code, rec.Body.String())
+ }
+}
+
+// The admin saves a manual banner; an article is already holding the homepage.
+// Echoing the request back would tell them the banner says something it does
+// not, so the response is re-resolved.
+func TestBreakingNewsSettingsHTTP_ResponseReflectsAnOverridingArticle(t *testing.T) {
+ conn := breakingNewsSettingsTestDB(t)
+ if _, err := conn.ExecContext(context.Background(),
+ "INSERT INTO articles (slug, title, breaking_news, pub_date) VALUES (?, ?, 1, UTC_TIMESTAMP() - INTERVAL 5 MINUTE)",
+ "dragonfly", "Dragonfly headliner"); err != nil {
+ t.Fatalf("seed article: %v", err)
+ }
+
+ rec := patchBreakingNews(t, conn, `{"enabled":true,"text":"Campus closed"}`)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, body %s", rec.Code, rec.Body.String())
+ }
+
+ payload := decodeBreakingNews(t, rec)
+ if payload["source"] != "article" {
+ t.Errorf("source = %v, want %q", payload["source"], "article")
+ }
+ if payload["text"] != "Dragonfly headliner" {
+ t.Errorf("text = %v, want the article headline", payload["text"])
+ }
+ manual, _ := payload["manual"].(map[string]any)
+ if manual["text"] != "Campus closed" {
+ t.Errorf("manual.text = %v, want the banner that was just saved", manual["text"])
+ }
+}
+
+func TestBreakingNewsSettingsHTTP_RejectsAnEnabledBannerWithNoText(t *testing.T) {
+ conn := breakingNewsSettingsTestDB(t)
+
+ rec := patchBreakingNews(t, conn, `{"enabled":true,"text":" "}`)
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400 (body %s)", rec.Code, rec.Body.String())
+ }
+}
diff --git a/server/internal/handlers/settings.go b/server/internal/handlers/settings.go
index a515be4..e7e6776 100644
--- a/server/internal/handlers/settings.go
+++ b/server/internal/handlers/settings.go
@@ -73,13 +73,16 @@ func PatchSiteSettings(conn *sql.DB) http.Handler {
// @Router /v1/settings/breaking-news [get]
func GetBreakingNews(conn *sql.DB) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- settings, err := db.GetBreakingNews(r.Context(), conn)
+ state, err := db.GetBreakingNewsState(r.Context(), conn)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to fetch breaking-news settings")
return
}
+ // The 60s public cache is what bounds how long an article-driven banner
+ // lags its article, and it is the same 60s the scheduler ticks on, so a
+ // scheduled story's banner lands within a tick of the story itself.
setAlwaysPublicCache(w)
- writeJSON(w, http.StatusOK, settings)
+ writeJSON(w, http.StatusOK, state)
})
}
@@ -107,16 +110,43 @@ func PatchBreakingNews(conn *sql.DB) http.Handler {
return
}
- if err := db.SetBreakingNews(r.Context(), conn, body); err != nil {
+ // Omitted means "leave the window alone", so it is read back rather
+ // than defaulted -- a plain enable/disable must not silently reset it.
+ window, err := db.GetBreakingNewsWindow(r.Context(), conn)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to fetch breaking-news settings")
+ return
+ }
+ if body.WindowHours != nil {
+ // 0 is the meaningful "no limit" value, so only a negative one is
+ // a mistake worth rejecting.
+ if *body.WindowHours < 0 {
+ writeError(w, http.StatusBadRequest, "window_hours cannot be negative")
+ return
+ }
+ window = db.NormalizeBreakingNewsWindow(*body.WindowHours)
+ }
+
+ manual := models.BreakingNewsSettings{Enabled: body.Enabled, Text: body.Text}
+ if err := db.SetBreakingNews(r.Context(), conn, manual, window); err != nil {
writeError(w, http.StatusInternalServerError, "failed to update breaking-news settings")
return
}
- state := "disabled"
+ logState := "disabled"
if body.Enabled {
- state = "enabled"
+ logState = "enabled"
+ }
+ activity.LogRequest(r, "settings_changed", "Breaking-news banner updated", "breaking_news", logState)
+
+ // Re-resolved rather than echoed: a flagged article may be overriding
+ // the manual banner that was just saved, and the admin should see the
+ // banner the public site is actually rendering.
+ state, err := db.GetBreakingNewsState(r.Context(), conn)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to fetch breaking-news settings")
+ return
}
- activity.LogRequest(r, "settings_changed", "Breaking-news banner updated", "breaking_news", state)
- writeJSON(w, http.StatusOK, body)
+ writeJSON(w, http.StatusOK, state)
})
}
diff --git a/server/internal/models/api_responses.go b/server/internal/models/api_responses.go
index 493b621..a47a433 100644
--- a/server/internal/models/api_responses.go
+++ b/server/internal/models/api_responses.go
@@ -302,15 +302,57 @@ type SiteSettingsPatchRequest struct {
}
// BreakingNewsSettings controls the breaking-news banner shown on the public
-// homepage: whether it is visible and the text it displays.
+// homepage: whether it is visible, the text it displays, and the article it
+// links to.
+//
+// ArticleSlug is the slug alone, not a path: the public site owns its own URL
+// shape (it routes articles under /article/), and it already composes links
+// this way for developing stories. It is empty for a hand-typed banner, which
+// has no article behind it and renders as plain text.
type BreakingNewsSettings struct {
- Enabled bool `json:"enabled"`
- Text string `json:"text"`
+ Enabled bool `json:"enabled"`
+ Text string `json:"text"`
+ ArticleSlug string `json:"article_slug,omitempty"`
}
-type BreakingNewsSettingsResponse = BreakingNewsSettings
+// Sources a banner can come from, in the order GetBreakingNewsState resolves
+// them: a published article flagged breaking wins over the hand-typed banner.
+const (
+ BreakingNewsSourceNone = "none"
+ BreakingNewsSourceManual = "manual"
+ BreakingNewsSourceArticle = "article"
+)
-type BreakingNewsSettingsPatchRequest = BreakingNewsSettings
+// BreakingNewsState is the banner as resolved for a request: the effective
+// Enabled/Text the public site renders, plus what produced them.
+//
+// The banner is derived, not stored. An article flagged breaking drives it
+// from the moment it is published -- which is what makes scheduling work:
+// nothing has to fire at publish time, because the article's pub_date passing
+// is itself the switch. Manual is the hand-typed fallback an admin sets in
+// Settings, kept separate so the settings screen can still edit it while an
+// article is overriding it.
+type BreakingNewsState struct {
+ BreakingNewsSettings
+ Source string `json:"source"`
+ ArticleTitle string `json:"article_title,omitempty"`
+ Manual BreakingNewsSettings `json:"manual"`
+ // WindowHours is 0 when a flagged article holds the banner indefinitely,
+ // which is the default; an admin sets a limit in Settings to opt in.
+ WindowHours int `json:"window_hours"`
+}
+
+type BreakingNewsSettingsResponse = BreakingNewsState
+
+// BreakingNewsSettingsPatchRequest edits the manual banner only; the
+// article-driven banner is changed by flagging or unflagging the article.
+// WindowHours is optional so a caller that only toggles the banner does not
+// have to know the current window; 0 means the banner has no time limit.
+type BreakingNewsSettingsPatchRequest struct {
+ Enabled bool `json:"enabled"`
+ Text string `json:"text"`
+ WindowHours *int `json:"window_hours,omitempty"`
+}
// HomepageCarouselSlide is one public Splide carousel item for Scalene's
// homepage. ImageURL may be empty for a text-only slide.
diff --git a/server/main.go b/server/main.go
index b02f5de..6974936 100644
--- a/server/main.go
+++ b/server/main.go
@@ -138,6 +138,9 @@ func main() {
if err := database.EnsureArticleAuthorsIndex(context.Background(), db); err != nil {
slog.Error("failed to index article authors; byline lookups scan the join table", "error", err)
}
+ if err := database.EnsureArticlesBreakingNewsIndex(context.Background(), db); err != nil {
+ slog.Error("failed to index breaking-news articles; the homepage banner lookup scans the table", "error", err)
+ }
// Deliberately not fatal, and deliberately after the column migration: the
// first FULLTEXT index on `articles` rebuilds the table, which on the