diff --git a/frontend/src/pages/articleView.tsx b/frontend/src/pages/articleView.tsx index f41db2f..efb6055 100644 --- a/frontend/src/pages/articleView.tsx +++ b/frontend/src/pages/articleView.tsx @@ -4,7 +4,7 @@ import { useNavigate } from "react-router-dom" import { publicSiteUrl } from "../auth/urls" import { useApiFetch } from "../hooks/useApiFetch" -type ArticleStatus = "Published" | "Draft" | "Archived" +type ArticleStatus = "Published" | "Scheduled" | "Draft" | "Archived" type ArticleItem = { id: string @@ -51,9 +51,17 @@ const PAGE_SIZE_OPTIONS = [25, 50, 100, 200] const DEFAULT_PAGE_SIZE = 25 const AUTHORS_PAGE_SIZE = 200 -const mapApiStatus = (status: string, activeTab: "all" | "trash"): ArticleStatus => { +const isFutureDate = (value?: string) => { + if (!value) return false + const timestamp = new Date(value).getTime() + return !Number.isNaN(timestamp) && timestamp > Date.now() +} + +const mapApiStatus = (status: string, activeTab: "all" | "trash", publishedDate?: string): ArticleStatus => { if (activeTab === "trash") return "Archived" - return status.toLowerCase() === "published" ? "Published" : "Draft" + if (status.toLowerCase() === "scheduled") return "Scheduled" + if (status.toLowerCase() !== "published") return "Draft" + return isFutureDate(publishedDate) ? "Scheduled" : "Published" } const formatArticleDate = (publishedDate?: string) => { @@ -290,7 +298,7 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article .map((author) => (author.name ?? "").trim()) .filter((name) => name.length > 0) .join(", "), - status: mapApiStatus(item.status, activeTab), + status: mapApiStatus(item.status, activeTab, item.published_date), // Drafts have no published_date, so fall back to when the row was created. date: formatArticleDate(item.published_date ?? item.creation_date), slug: item.slug, @@ -610,7 +618,9 @@ function ArticleView({ pageTitle = "Articles", fixedType, excludeType }: Article diff --git a/frontend/src/pages/editArticleView.tsx b/frontend/src/pages/editArticleView.tsx index 05f3e72..51b12e3 100644 --- a/frontend/src/pages/editArticleView.tsx +++ b/frontend/src/pages/editArticleView.tsx @@ -4,6 +4,7 @@ import { useNavigate, useParams } from "react-router-dom" import { useApiFetch } from "../hooks/useApiFetch" import { publicSiteUrl } from "../auth/urls" import TrixEditor from "../components/TrixEditor" +import { DateTimeField } from "../components/ui/datetime-field" // Lazy-loaded so the heavy yoastseo bundle only loads when editing an article. const SeoAnalysis = lazy(() => import("../components/SeoAnalysis")) @@ -17,6 +18,7 @@ type ApiArticleDetail = { content: string excerpt?: string status?: string + published_date?: string comment_status?: string featured_image?: string breaking_news?: boolean @@ -40,6 +42,7 @@ type PatchPayload = { excerpt: string content: string status: EditableStatus + published_date?: string comment_status: string photo_url: string breaking_news: boolean @@ -90,6 +93,39 @@ const slugifyCategory = (value: string): string => .replace(/[^a-z0-9]+/g, "-") .replace(/^-+|-+$/g, "") +const toLocalInput = (value?: string): string => { + if (!value) return "" + const date = new Date(value) + if (Number.isNaN(date.getTime())) return "" + const pad = (n: number) => String(n).padStart(2, "0") + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` +} + +const localInputToISO = (value: string): string => { + const date = new Date(value) + if (Number.isNaN(date.getTime())) return "" + return date.toISOString() +} + +const isFutureDate = (value: string): boolean => { + if (!value) return false + const timestamp = new Date(value).getTime() + return !Number.isNaN(timestamp) && timestamp > Date.now() +} + +const formatPublishDate = (value: string): string => { + if (!value) return "" + const date = new Date(value) + if (Number.isNaN(date.getTime())) return "" + return date.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }) +} + // ArticleView caches list results in sessionStorage keyed by query; clear those // entries so the list refetches after an article is created or edited. const clearArticleListCache = () => { @@ -167,6 +203,7 @@ function EditArticleView() { }, [excerpt]) const [content, setContent] = useState("") const [status, setStatus] = useState("draft") + const [publishedAt, setPublishedAt] = useState("") const [commentStatus, setCommentStatus] = useState("open") const [photoURL, setPhotoURL] = useState("") const [breakingNews, setBreakingNews] = useState(false) @@ -223,7 +260,8 @@ function EditArticleView() { // has been saved yet. setMetaDescription(payload.seo?.meta_description ?? payload.excerpt ?? "") setContent(payload.content ?? "") - setStatus((payload.status ?? "draft").toLowerCase() === "published" ? "published" : "draft") + setStatus(["published", "scheduled"].includes((payload.status ?? "draft").toLowerCase()) ? "published" : "draft") + setPublishedAt(toLocalInput(payload.published_date)) setCommentStatus(normalizeCommentStatus(payload.comment_status)) setPhotoURL(payload.featured_image ?? "") setBreakingNews(Boolean(payload.breaking_news)) @@ -466,6 +504,12 @@ function EditArticleView() { setIsSaving(false) return } + const publishedDateISO = effectiveStatus === "published" && publishedAt ? localInputToISO(publishedAt) : "" + if (effectiveStatus === "published" && publishedAt && !publishedDateISO) { + setError("Publish date is invalid.") + setIsSaving(false) + return + } try { if (isNew) { @@ -478,6 +522,7 @@ function EditArticleView() { content: content.trim(), excerpt: excerpt.trim(), status: effectiveStatus, + ...(publishedDateISO ? { published_date: publishedDateISO } : {}), comment_status: commentStatus.trim() || "open", photo_url: photoURL.trim(), breaking_news: breakingNews, @@ -510,6 +555,7 @@ function EditArticleView() { excerpt: excerpt.trim(), content: content.trim(), status: effectiveStatus, + ...(publishedDateISO ? { published_date: publishedDateISO } : {}), comment_status: commentStatus.trim() || "open", photo_url: photoURL.trim(), breaking_news: breakingNews, @@ -532,6 +578,7 @@ function EditArticleView() { } if (nextStatus) { setStatus(nextStatus) + if (nextStatus === "draft") setPublishedAt("") } clearArticleListCache() setSuccessMessage("Article saved.") @@ -547,6 +594,12 @@ function EditArticleView() { const selectClass = "w-full px-3 py-2 rounded-lg border border-border bg-background text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40 focus:border-primary transition" const labelClass = "flex flex-col gap-1.5" const labelTextClass = "text-xs font-semibold text-muted-foreground uppercase tracking-wide" + const publishDateInFuture = status === "published" && isFutureDate(publishedAt) + const publishDateHint = publishedAt + ? publishDateInFuture + ? `Goes on the site ${formatPublishDate(publishedAt)}.` + : `Published date: ${formatPublishDate(publishedAt)}.` + : "Blank publishes immediately." const taxonomyBySlug = useMemo(() => new Map(taxonomyItems.map((item) => [item.slug, item])), [taxonomyItems]) const categoryGroups = useMemo(() => { const sections = taxonomyItems @@ -770,12 +823,30 @@ function EditArticleView() { + {status === "published" ? ( + + ) : null} +
Author
@@ -989,7 +1060,7 @@ function EditArticleView() { onClick={() => void saveArticle("published")} type="button" > - {isSaving ? "Publishing..." : "Publish"} + {isSaving ? (publishDateInFuture ? "Scheduling..." : "Publishing...") : (publishDateInFuture ? "Schedule" : "Publish")}
diff --git a/server/internal/database/comments.go b/server/internal/database/comments.go index 4c09d22..4da45aa 100644 --- a/server/internal/database/comments.go +++ b/server/internal/database/comments.go @@ -161,6 +161,9 @@ func GetArticleCommentTargetBySlug(ctx context.Context, conn *sql.DB, slug strin SELECT id, COALESCE(comment_status, '') FROM articles WHERE slug = ? + AND pub_date IS NOT NULL + AND pub_date <= UTC_TIMESTAMP() + AND archived_at IS NULL LIMIT 1 `, slug).Scan(&articleID, &commentStatus) if err == nil { @@ -286,7 +289,7 @@ func GetCommentByID(ctx context.Context, conn *sql.DB, id int64) (Comment, error func ArticleExistsBySlug(ctx context.Context, conn *sql.DB, slug string) (bool, error) { var exists int - err := conn.QueryRowContext(ctx, "SELECT 1 FROM articles WHERE slug = ? LIMIT 1", slug).Scan(&exists) + err := conn.QueryRowContext(ctx, "SELECT 1 FROM articles WHERE slug = ? AND pub_date IS NOT NULL AND pub_date <= UTC_TIMESTAMP() AND archived_at IS NULL LIMIT 1", slug).Scan(&exists) if err == nil { return true, nil } diff --git a/server/internal/database/http_models.go b/server/internal/database/http_models.go index 85d879f..a15af0b 100644 --- a/server/internal/database/http_models.go +++ b/server/internal/database/http_models.go @@ -38,10 +38,10 @@ var ArticleColumns = []string{ "id", "title", "slug", "description", "text", "excerpt", "tags", "categories", "pub_date", "mod_date", "priority", "breaking_news", "comment_status", "photo_url", - "focus_keyword", "meta_description", "seo_title", "creation_date", + "focus_keyword", "meta_description", "seo_title", "creation_date", "scheduled_pub_date", } -const articleSelectColumnsQualified = "a.`id`, a.`title`, a.`slug`, a.`description`, a.`text`, a.`excerpt`, a.`tags`, a.`categories`, a.`pub_date`, a.`mod_date`, a.`priority`, a.`breaking_news`, a.`comment_status`, a.`photo_url`, a.`focus_keyword`, a.`meta_description`, a.`seo_title`, a.`creation_date`" +const articleSelectColumnsQualified = "a.`id`, a.`title`, a.`slug`, a.`description`, a.`text`, a.`excerpt`, a.`tags`, a.`categories`, a.`pub_date`, a.`mod_date`, a.`priority`, a.`breaking_news`, a.`comment_status`, a.`photo_url`, a.`focus_keyword`, a.`meta_description`, a.`seo_title`, a.`creation_date`, a.`scheduled_pub_date`" // Image/photo URLs are canonicalized upstream in the WordPress ETL (see // wordpress-etl Utils/MediaURL) so `photo_url` and inline body images are stored @@ -145,12 +145,13 @@ func ScanArticle(rows *sql.Rows) (models.Article, error) { metaDescription sql.NullString seoTitle sql.NullString creationDate sql.NullTime + scheduledDate sql.NullTime ) err := rows.Scan( &a.ID, &a.Title, &slug, &description, &text, &excerpt, &tags, &categories, &pubDate, &ignoredMod, &priority, &breakingNews, &commentStatus, &photoURL, - &focusKeyword, &metaDescription, &seoTitle, &creationDate, + &focusKeyword, &metaDescription, &seoTitle, &creationDate, &scheduledDate, ) if err != nil { return models.Article{}, err @@ -185,6 +186,11 @@ func ScanArticle(rows *sql.Rows) (models.Article, error) { t := pubDate.Time a.PublishedAt = &t a.Status = models.ArticleStatusPublished + } else if scheduledDate.Valid { + t := scheduledDate.Time + a.PublishedAt = &t + a.ScheduledAt = &t + a.Status = models.ArticleStatusScheduled } else { a.Status = models.ArticleStatusDraft } @@ -334,7 +340,7 @@ func GetRelatedArticlesBySlug(ctx context.Context, conn *sql.DB, slug string, k // candidate must be live content regardless of who is asking -- an // unpublished or soft-deleted article is not something to link to from // anywhere, including the CMS preview. - "WHERE src.slug = ? AND a.pub_date IS NOT NULL AND a.archived_at IS NULL " + + "WHERE src.slug = ? AND a.pub_date IS NOT NULL AND a.pub_date <= UTC_TIMESTAMP() AND a.archived_at IS NULL " + "ORDER BY VEC_DISTANCE_EUCLIDEAN(cand_vec.embedding, src_vec.embedding), a.id DESC " + "LIMIT ?" @@ -363,10 +369,10 @@ func SearchArticles(ctx context.Context, conn *sql.DB, term string, limit, offse } like := "%" + trimmedTerm + "%" - query := "SELECT `id`, `title`, `slug`, `description`, `text`, `excerpt`, `tags`, `categories`, `pub_date`, `mod_date`, `priority`, `breaking_news`, `comment_status`, `photo_url`, `focus_keyword`, `meta_description`, `seo_title`, `creation_date` FROM `articles` " + + query := "SELECT `id`, `title`, `slug`, `description`, `text`, `excerpt`, `tags`, `categories`, `pub_date`, `mod_date`, `priority`, `breaking_news`, `comment_status`, `photo_url`, `focus_keyword`, `meta_description`, `seo_title`, `creation_date`, `scheduled_pub_date` FROM `articles` " + // Search is public, so it stays pinned to live content: published and not // soft-deleted. Archived rows were previously reachable here. - "WHERE `pub_date` IS NOT NULL AND `archived_at` IS NULL AND (`title` LIKE ? OR `tags` LIKE ? OR `text` LIKE ?) " + + "WHERE `pub_date` IS NOT NULL AND `pub_date` <= UTC_TIMESTAMP() AND `archived_at` IS NULL AND (`title` LIKE ? OR `tags` LIKE ? OR `text` LIKE ?) " + "ORDER BY CASE " + "WHEN `title` LIKE ? THEN 1 " + "WHEN `tags` LIKE ? THEN 2 " + @@ -578,8 +584,17 @@ func IsCanonicalSlug(value string) bool { func ArticleInputToDBFields(body models.ArticleInput) []any { var publishedAt any + var scheduledAt any if body.Status == models.ArticleStatusPublished { publishedAt = time.Now().UTC().Format("2006-01-02 15:04:05") + if supplied := ParsePublishedAt(body.PublishedDate); supplied != nil { + if supplied.After(time.Now().UTC()) { + publishedAt = nil + scheduledAt = supplied.UTC().Format("2006-01-02 15:04:05") + } else { + publishedAt = supplied.UTC().Format("2006-01-02 15:04:05") + } + } } slug := normalizeSlug(body.Slug) if slug == "" { @@ -615,13 +630,23 @@ func ArticleInputToDBFields(body models.ArticleInput) []any { // Stamp creation_date so a draft has a date of its own: it is what the // CMS listing sorts unpublished rows by (pub_date is NULL until publish). time.Now().UTC().Format("2006-01-02 15:04:05"), + scheduledAt, } } func ArticleToDBFields(body models.Article) []any { var publishedAt any - if body.PublishedAt != nil { - publishedAt = body.PublishedAt.UTC().Format("2006-01-02 15:04:05") + var scheduledAt any + if body.Status == models.ArticleStatusDraft { + // Draft wins over any stale published_date in a full replacement payload. + } else if body.Status == models.ArticleStatusScheduled && body.PublishedAt != nil { + scheduledAt = body.PublishedAt.UTC().Format("2006-01-02 15:04:05") + } else if body.PublishedAt != nil { + if body.PublishedAt.After(time.Now().UTC()) { + scheduledAt = body.PublishedAt.UTC().Format("2006-01-02 15:04:05") + } else { + publishedAt = body.PublishedAt.UTC().Format("2006-01-02 15:04:05") + } } else if body.Status == models.ArticleStatusPublished { publishedAt = time.Now().UTC().Format("2006-01-02 15:04:05") } @@ -640,5 +665,6 @@ func ArticleToDBFields(body models.Article) []any { strings.TrimSpace(body.FocusKeyword), strings.TrimSpace(body.MetaDescription), strings.TrimSpace(body.SEOTitle), + scheduledAt, } } diff --git a/server/internal/database/http_models_test.go b/server/internal/database/http_models_test.go index e6ead0e..f62e8e2 100644 --- a/server/internal/database/http_models_test.go +++ b/server/internal/database/http_models_test.go @@ -97,3 +97,25 @@ func TestArticleInputToDBFields_PreservesBreakingNews(t *testing.T) { t.Fatal("breaking_news field was not preserved") } } + +func TestArticleInputToDBFields_StoresFuturePublishDateAsSchedule(t *testing.T) { + fields := ArticleInputToDBFields(models.ArticleInput{ + Title: "Tomorrow's story", + Content: "Body", + Status: models.ArticleStatusPublished, + PublishedDate: "2030-05-06T14:30:00Z", + }) + + publishedDateFieldIndex := 6 + if fields[publishedDateFieldIndex] != nil { + t.Fatalf("pub_date = %v, want nil until schedule is due", fields[publishedDateFieldIndex]) + } + scheduledDateFieldIndex := 18 + got, ok := fields[scheduledDateFieldIndex].(string) + if !ok { + t.Fatalf("scheduled_pub_date field has type %T, want string", fields[scheduledDateFieldIndex]) + } + if got != "2030-05-06 14:30:00" { + t.Fatalf("scheduled_pub_date = %q, want scheduled timestamp", got) + } +} diff --git a/server/internal/database/scheduling.go b/server/internal/database/scheduling.go new file mode 100644 index 0000000..137ce35 --- /dev/null +++ b/server/internal/database/scheduling.go @@ -0,0 +1,137 @@ +package database + +import ( + "context" + "database/sql" + "log/slog" + "time" +) + +const DefaultScheduleInterval = time.Minute + +type ScheduleResult struct { + ArticlesPublished int64 + PollsClosed int64 +} + +func RunScheduleTick(ctx context.Context, conn *sql.DB) (ScheduleResult, error) { + var result ScheduleResult + + articles, err := PublishDueArticles(ctx, conn) + if err != nil { + return result, err + } + result.ArticlesPublished = articles + + polls, err := ReconcileDuePolls(ctx, conn) + if err != nil { + return result, err + } + result.PollsClosed = polls + + return result, nil +} + +func PublishDueArticles(ctx context.Context, conn *sql.DB) (int64, error) { + res, err := conn.ExecContext(ctx, ` + UPDATE articles + SET pub_date = scheduled_pub_date, + scheduled_pub_date = NULL + WHERE pub_date IS NULL + AND scheduled_pub_date IS NOT NULL + AND scheduled_pub_date <= UTC_TIMESTAMP() + AND archived_at IS NULL + `) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func ReconcileDuePolls(ctx context.Context, conn *sql.DB) (int64, error) { + tx, err := conn.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + var liveID int64 + err = tx.QueryRowContext(ctx, ` + SELECT id + FROM `+PollsTableName+` + WHERE status = ? + AND (starts_at IS NULL OR starts_at <= NOW()) + AND (ends_at IS NULL OR ends_at >= NOW()) + ORDER BY COALESCE(starts_at, created_at) DESC, id DESC + LIMIT 1 + FOR UPDATE + `, PollStatusActive).Scan(&liveID) + if err == sql.ErrNoRows { + if err := tx.Commit(); err != nil { + return 0, err + } + return 0, nil + } + if err != nil { + return 0, err + } + + res, err := tx.ExecContext(ctx, ` + UPDATE `+PollsTableName+` + SET status = ? + WHERE status = ? + AND id <> ? + AND (starts_at IS NULL OR starts_at <= NOW()) + `, PollStatusClosed, PollStatusActive, liveID) + if err != nil { + return 0, err + } + closed, err := res.RowsAffected() + if err != nil { + return 0, err + } + + if err := tx.Commit(); err != nil { + return 0, err + } + return closed, nil +} + +func RunScheduler(ctx context.Context, conn *sql.DB, interval time.Duration, logger *slog.Logger) { + if conn == nil { + return + } + if interval <= 0 { + interval = DefaultScheduleInterval + } + if logger == nil { + logger = slog.Default() + } + + run := func() { + result, err := RunScheduleTick(ctx, conn) + if err != nil { + logger.Error("schedule tick failed", "error", err) + return + } + if result.ArticlesPublished > 0 || result.PollsClosed > 0 { + logger.Info( + "schedule tick applied", + "articles_published", result.ArticlesPublished, + "polls_closed", result.PollsClosed, + ) + } + } + + run() + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + run() + } + } +} diff --git a/server/internal/database/seo.go b/server/internal/database/seo.go index 860e86e..cd62c69 100644 --- a/server/internal/database/seo.go +++ b/server/internal/database/seo.go @@ -34,6 +34,7 @@ func GetSEOAudit(ctx context.Context, conn *sql.DB) (models.SEOAuditResponse, er COALESCE(seo_title, ''), COALESCE(meta_description, ''), COALESCE(focus_keyword, '') FROM articles WHERE pub_date IS NOT NULL + AND pub_date <= UTC_TIMESTAMP() AND pub_date >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL ? MONTH) ORDER BY pub_date DESC, id DESC `, auditWindowMonths) diff --git a/server/internal/database/taxonomy.go b/server/internal/database/taxonomy.go index 80932cf..43acd2f 100644 --- a/server/internal/database/taxonomy.go +++ b/server/internal/database/taxonomy.go @@ -167,7 +167,7 @@ func RebuildTaxonomyArticleCounts(ctx context.Context, conn *sql.DB) error { continue } var count int64 - query := "SELECT COUNT(*) FROM `articles` WHERE `archived_at` IS NULL AND `pub_date` IS NOT NULL AND " + condition + query := "SELECT COUNT(*) FROM `articles` WHERE `archived_at` IS NULL AND `pub_date` IS NOT NULL AND `pub_date` <= UTC_TIMESTAMP() AND " + condition if err := conn.QueryRowContext(ctx, query, args...).Scan(&count); err != nil { return err } diff --git a/server/internal/database/users.go b/server/internal/database/users.go index d67e0fa..7ac68e8 100644 --- a/server/internal/database/users.go +++ b/server/internal/database/users.go @@ -16,7 +16,8 @@ func EnsureArticlesSchema(ctx context.Context, conn *sql.DB) error { ADD COLUMN IF NOT EXISTS breaking_news BOOL NULL DEFAULT 0, ADD COLUMN IF NOT EXISTS focus_keyword LONGTEXT NULL DEFAULT NULL, ADD COLUMN IF NOT EXISTS meta_description LONGTEXT NULL DEFAULT NULL, - ADD COLUMN IF NOT EXISTS seo_title LONGTEXT NULL DEFAULT NULL + ADD COLUMN IF NOT EXISTS seo_title LONGTEXT NULL DEFAULT NULL, + ADD COLUMN IF NOT EXISTS scheduled_pub_date DATETIME NULL DEFAULT NULL `) return err } diff --git a/server/internal/handlers/comments_integration_test.go b/server/internal/handlers/comments_integration_test.go index 8beacd1..ac9dc3a 100644 --- a/server/internal/handlers/comments_integration_test.go +++ b/server/internal/handlers/comments_integration_test.go @@ -61,7 +61,9 @@ func commentHTTPTestDB(t *testing.T) *sql.DB { CREATE TABLE articles ( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, slug VARCHAR(255) NOT NULL UNIQUE, - comment_status VARCHAR(32) + comment_status VARCHAR(32), + pub_date DATETIME NULL, + archived_at DATETIME NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `); err != nil { t.Fatalf("create articles table: %v", err) @@ -73,7 +75,7 @@ func commentHTTPTestDB(t *testing.T) *sql.DB { func seedCommentArticle(t *testing.T, conn *sql.DB, slug, commentStatus string) int64 { t.Helper() - result, err := conn.ExecContext(context.Background(), "INSERT INTO articles (slug, comment_status) VALUES (?, ?)", slug, commentStatus) + result, err := conn.ExecContext(context.Background(), "INSERT INTO articles (slug, comment_status, pub_date) VALUES (?, ?, UTC_TIMESTAMP())", slug, commentStatus) if err != nil { t.Fatalf("seed article: %v", err) } diff --git a/server/internal/handlers/handlers.go b/server/internal/handlers/handlers.go index 5e36ef6..e85d636 100644 --- a/server/internal/handlers/handlers.go +++ b/server/internal/handlers/handlers.go @@ -1113,7 +1113,7 @@ type ArticleParams struct { func queryArticles(r *http.Request, conn *sql.DB, params ArticleParams, limit, offset int) (*sql.Rows, error) { q := r.URL.Query() conditions, args := articleQueryFilters(r, params) - query := "SELECT `id`, `title`, `slug`, `description`, `text`, `excerpt`, `tags`, `categories`, `pub_date`, `mod_date`, `priority`, `breaking_news`, `comment_status`, `photo_url`, `focus_keyword`, `meta_description`, `seo_title`, `creation_date` FROM `articles`" + query := "SELECT `id`, `title`, `slug`, `description`, `text`, `excerpt`, `tags`, `categories`, `pub_date`, `mod_date`, `priority`, `breaking_news`, `comment_status`, `photo_url`, `focus_keyword`, `meta_description`, `seo_title`, `creation_date`, `scheduled_pub_date` FROM `articles`" if len(conditions) > 0 { query += " WHERE " + strings.Join(conditions, " AND ") } @@ -1193,7 +1193,7 @@ func articleQueryFilters(r *http.Request, params ArticleParams) ([]string, []any // excerpt-only, but unpublished headlines still must not leak. Editors are // identified by OptionalAuth on the route and keep the full filter set. if !isEditor { - conditions = append(conditions, "`pub_date` IS NOT NULL", "`archived_at` IS NULL") + conditions = append(conditions, "`pub_date` IS NOT NULL", "`pub_date` <= UTC_TIMESTAMP()", "`archived_at` IS NULL") } else if _, archivedProvided := q["archived"]; archivedProvided { archivedRaw := strings.ToLower(strings.TrimSpace(q.Get("archived"))) switch archivedRaw { @@ -1230,7 +1230,9 @@ func articleQueryFilters(r *http.Request, params ArticleParams) ([]string, []any if status := strings.TrimSpace(q.Get("status")); status != "" && isEditor { switch strings.ToLower(status) { case string(models.ArticleStatusDraft): - conditions = append(conditions, "`pub_date` IS NULL") + conditions = append(conditions, "`pub_date` IS NULL", "`scheduled_pub_date` IS NULL") + case string(models.ArticleStatusScheduled): + conditions = append(conditions, "`pub_date` IS NULL", "`scheduled_pub_date` IS NOT NULL") case string(models.ArticleStatusPublished): conditions = append(conditions, "`pub_date` IS NOT NULL") } @@ -1404,7 +1406,7 @@ func articleDetailCondition(r *http.Request) string { if _, isEditor := middleware.UserFromContext(r.Context()); isEditor { return "`slug` = ?" } - return "`slug` = ? AND `pub_date` IS NOT NULL AND `archived_at` IS NULL" + return "`slug` = ? AND `pub_date` IS NOT NULL AND `pub_date` <= UTC_TIMESTAMP() AND `archived_at` IS NULL" } // @Summary Get an article by slug @@ -1932,9 +1934,13 @@ func PostArticles(conn *sql.DB) http.HandlerFunc { writeError(w, http.StatusBadRequest, "slug must be canonical") return } + if strings.TrimSpace(body.PublishedDate) != "" && db.ParsePublishedAt(body.PublishedDate) == nil { + writeError(w, http.StatusBadRequest, "published_date has invalid format") + return + } fields := db.ArticleInputToDBFields(body) result, err := db.Insert(r.Context(), conn, "articles", - []string{"title", "slug", "description", "text", "excerpt", "categories", "pub_date", "mod_date", "priority", "breaking_news", "comment_status", "photo_url", "tags", "metadata", "focus_keyword", "meta_description", "seo_title", "creation_date"}, + []string{"title", "slug", "description", "text", "excerpt", "categories", "pub_date", "mod_date", "priority", "breaking_news", "comment_status", "photo_url", "tags", "metadata", "focus_keyword", "meta_description", "seo_title", "creation_date", "scheduled_pub_date"}, fields..., ) if err != nil { @@ -2017,7 +2023,7 @@ func PutArticle(conn *sql.DB) http.HandlerFunc { fields := db.ArticleToDBFields(body) fields = append(fields, slug) result, err := db.Update(r.Context(), conn, "articles", - []string{"title", "slug", "excerpt", "text", "categories", "pub_date", "mod_date", "priority", "breaking_news", "comment_status", "photo_url", "focus_keyword", "meta_description", "seo_title"}, + []string{"title", "slug", "excerpt", "text", "categories", "pub_date", "mod_date", "priority", "breaking_news", "comment_status", "photo_url", "focus_keyword", "meta_description", "seo_title", "scheduled_pub_date"}, "`slug` = ?", fields..., ) @@ -2126,6 +2132,11 @@ func PatchArticle(conn *sql.DB) http.HandlerFunc { "meta_description": "meta_description", "seo_title": "seo_title", } + var publishedDateValue any + var scheduledDateValue any + publishedDateSet := false + var statusValue any + statusSet := false for jsonField, column := range columnByJSONField { v, ok := body[jsonField] if !ok { @@ -2161,8 +2172,14 @@ func PatchArticle(conn *sql.DB) http.HandlerFunc { writeError(w, http.StatusBadRequest, "published_date has invalid format") return } - setCols = append(setCols, column) - setArgs = append(setArgs, t.UTC().Format("2006-01-02 15:04:05")) + if t.After(time.Now().UTC()) { + publishedDateValue = nil + scheduledDateValue = t.UTC().Format("2006-01-02 15:04:05") + } else { + publishedDateValue = t.UTC().Format("2006-01-02 15:04:05") + scheduledDateValue = nil + } + publishedDateSet = true case "status": s, ok := v.(string) if !ok { @@ -2172,11 +2189,11 @@ func PatchArticle(conn *sql.DB) http.HandlerFunc { status := models.ArticleStatus(strings.TrimSpace(s)) switch status { case models.ArticleStatusDraft: - setCols = append(setCols, column) - setArgs = append(setArgs, nil) + statusValue = nil + statusSet = true case models.ArticleStatusPublished: - setCols = append(setCols, column) - setArgs = append(setArgs, time.Now().UTC().Format("2006-01-02 15:04:05")) + statusValue = time.Now().UTC().Format("2006-01-02 15:04:05") + statusSet = true default: writeError(w, http.StatusBadRequest, "status must be draft or published") return @@ -2207,6 +2224,24 @@ func PatchArticle(conn *sql.DB) http.HandlerFunc { setArgs = append(setArgs, v) } } + if statusSet || publishedDateSet { + setCols = append(setCols, "pub_date") + if statusSet && statusValue == nil { + setArgs = append(setArgs, nil) + } else if publishedDateSet { + setArgs = append(setArgs, publishedDateValue) + } else { + setArgs = append(setArgs, statusValue) + } + setCols = append(setCols, "scheduled_pub_date") + if statusSet && statusValue == nil { + setArgs = append(setArgs, nil) + } else if publishedDateSet { + setArgs = append(setArgs, scheduledDateValue) + } else { + setArgs = append(setArgs, nil) + } + } if len(setCols) == 0 && authorIDs == nil { writeError(w, http.StatusBadRequest, "no valid fields to update") return diff --git a/server/internal/handlers/handlers_test.go b/server/internal/handlers/handlers_test.go index dbf2e3e..813ca82 100644 --- a/server/internal/handlers/handlers_test.go +++ b/server/internal/handlers/handlers_test.go @@ -290,6 +290,9 @@ func TestArticleQueryFilters_AnonymousIsPinnedToPublished(t *testing.T) { if !strings.Contains(joined, "`pub_date` IS NOT NULL") { t.Fatalf("anonymous listing must be published-only, got %q", joined) } + if !strings.Contains(joined, "`pub_date` <= UTC_TIMESTAMP()") { + t.Fatalf("anonymous listing must exclude scheduled articles, got %q", joined) + } if !strings.Contains(joined, "`archived_at` IS NULL") { t.Fatalf("anonymous listing must exclude archived, got %q", joined) } @@ -313,11 +316,29 @@ func TestArticleQueryFilters_EditorKeepsDraftAndArchivedFilters(t *testing.T) { if !strings.Contains(joined, "`pub_date` IS NULL") { t.Fatalf("editor must keep the draft filter, got %q", joined) } + if !strings.Contains(joined, "`scheduled_pub_date` IS NULL") { + t.Fatalf("editor draft filter must exclude scheduled articles, got %q", joined) + } if !strings.Contains(joined, "`archived_at` IS NOT NULL") { t.Fatalf("editor must keep the archived filter, got %q", joined) } } +func TestArticleQueryFilters_EditorCanFilterScheduled(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/v1/articles?status=scheduled", nil) + req = req.WithContext(middleware.ContextWithUser(req.Context(), &models.User{ID: 1, Role: models.RoleAdmin})) + + conditions, _ := articleQueryFilters(req, ArticleParams{}) + joined := strings.Join(conditions, " AND ") + + if !strings.Contains(joined, "`pub_date` IS NULL") { + t.Fatalf("scheduled filter must exclude already-published articles, got %q", joined) + } + if !strings.Contains(joined, "`scheduled_pub_date` IS NOT NULL") { + t.Fatalf("scheduled filter must require a schedule date, got %q", joined) + } +} + func TestArticleQueryFilters_AuthorSearchMatchesNameOrLogin(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/v1/articles?author=Jane", nil) @@ -345,6 +366,9 @@ func TestArticleDetailCondition_AnonymousSeesOnlyLiveArticles(t *testing.T) { if !strings.Contains(got, "`pub_date` IS NOT NULL") { t.Fatalf("anonymous lookup must exclude drafts, got %q", got) } + if !strings.Contains(got, "`pub_date` <= UTC_TIMESTAMP()") { + t.Fatalf("anonymous lookup must exclude scheduled articles, got %q", got) + } if !strings.Contains(got, "`archived_at` IS NULL") { t.Fatalf("anonymous lookup must exclude archived articles, got %q", got) } diff --git a/server/internal/handlers/public_site.go b/server/internal/handlers/public_site.go index 90af7eb..a22fff1 100644 --- a/server/internal/handlers/public_site.go +++ b/server/internal/handlers/public_site.go @@ -27,7 +27,7 @@ func GetRandomArticle(conn *sql.DB) http.Handler { // Unauthenticated endpoint, so it is pinned to published, non-archived // rows the same way the public article listing is. var slug, title string - err := conn.QueryRowContext(r.Context(), "SELECT `slug`, `title` FROM `articles` WHERE `pub_date` IS NOT NULL AND `archived_at` IS NULL AND TRIM(COALESCE(`slug`, '')) <> '' ORDER BY RAND() LIMIT 1").Scan(&slug, &title) + err := conn.QueryRowContext(r.Context(), "SELECT `slug`, `title` FROM `articles` WHERE `pub_date` IS NOT NULL AND `pub_date` <= UTC_TIMESTAMP() AND `archived_at` IS NULL AND TRIM(COALESCE(`slug`, '')) <> '' ORDER BY RAND() LIMIT 1").Scan(&slug, &title) if err == sql.ErrNoRows { writeError(w, http.StatusNotFound, "no published articles") return @@ -53,7 +53,7 @@ func GetRandomArticle(conn *sql.DB) http.Handler { // @Router /v1/sitemap/slugs [get] func GetSitemapSlugs(conn *sql.DB) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - rows, err := conn.QueryContext(r.Context(), "SELECT `slug`, COALESCE(`mod_date`, `pub_date`) FROM `articles` WHERE `pub_date` IS NOT NULL AND `archived_at` IS NULL AND TRIM(COALESCE(`slug`, '')) <> '' ORDER BY `pub_date` DESC") + rows, err := conn.QueryContext(r.Context(), "SELECT `slug`, COALESCE(`mod_date`, `pub_date`) FROM `articles` WHERE `pub_date` IS NOT NULL AND `pub_date` <= UTC_TIMESTAMP() AND `archived_at` IS NULL AND TRIM(COALESCE(`slug`, '')) <> '' ORDER BY `pub_date` DESC") if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return diff --git a/server/internal/models/types.go b/server/internal/models/types.go index 1040b95..a3a7f45 100644 --- a/server/internal/models/types.go +++ b/server/internal/models/types.go @@ -60,6 +60,7 @@ type ArticleStatus string const ( ArticleStatusDraft ArticleStatus = "draft" + ArticleStatusScheduled ArticleStatus = "scheduled" ArticleStatusPublished ArticleStatus = "published" ) @@ -127,6 +128,7 @@ type Article struct { SEOTitle string `json:"seo_title"` CreatedAt *time.Time `json:"creation_date,omitempty"` PublishedAt *time.Time `json:"published_date,omitempty"` + ScheduledAt *time.Time `json:"scheduled_date,omitempty"` } type ArticleOverview struct { @@ -155,6 +157,7 @@ type ArticleInput struct { IsFeatured bool `json:"is_featured"` BreakingNews bool `json:"breaking_news"` Status ArticleStatus `json:"status"` + PublishedDate string `json:"published_date,omitempty"` CommentStatus string `json:"comment_status,omitempty"` FocusKeyword string `json:"focus_keyword,omitempty"` MetaDescription string `json:"meta_description,omitempty"` @@ -171,6 +174,7 @@ type ArticlePatch struct { IsFeatured *bool `json:"is_featured,omitempty"` BreakingNews *bool `json:"breaking_news,omitempty"` Status *ArticleStatus `json:"status,omitempty"` + PublishedDate *string `json:"published_date,omitempty"` CommentStatus *string `json:"comment_status,omitempty"` FocusKeyword *string `json:"focus_keyword,omitempty"` MetaDescription *string `json:"meta_description,omitempty"` diff --git a/server/main.go b/server/main.go index 3d5e8d0..00fe60d 100644 --- a/server/main.go +++ b/server/main.go @@ -356,6 +356,10 @@ func run(deps runDeps, conn *sql.DB) error { routes.Register(mux, conn, deps.oidcVerifier, deps.oidcCfg, deps.spamChecker) server := deps.newServer(cert, mux, slog.Default()) + schedulerCtx, stopScheduler := context.WithCancel(context.Background()) + defer stopScheduler() + go database.RunScheduler(schedulerCtx, conn, database.DefaultScheduleInterval, slog.Default()) + serverErr := make(chan error, 1) go func() { var err error