Add scheduled series auto-launch for series card, Start Here, and study guide; v2.0.36
Publish Container / docker (push) Successful in 14m20s
Publish Container / docker (push) Successful in 14m20s
One scheduled date/time in the Current Series admin panel archives the active series to Finished Books and atomically swaps in new content for the series card, Start Here steps, and Companion Study Guide. Server checks every hour via setInterval; also runs on restart so the transition fires even if the server was down at the scheduled moment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
06f89d6436
commit
f254e3ec19
5 files changed
+252
-2
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "siteforge",
|
||||
"private": true,
|
||||
"version": "2.0.35",
|
||||
"version": "2.0.36",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -45,7 +45,7 @@ import { recordVisitor } from './server/routes/analytics.js'
|
||||
|
||||
// Route registrars
|
||||
import { register as registerAdminAuth } from './server/routes/admin-auth.js'
|
||||
import { register as registerAdminContent } from './server/routes/admin-content.js'
|
||||
import { register as registerAdminContent, runScheduledSeriesTransition } from './server/routes/admin-content.js'
|
||||
import { register as registerAdminAssets } from './server/routes/admin-assets.js'
|
||||
import { register as registerAdminBackup } from './server/routes/admin-backup.js'
|
||||
import { register as registerStudyAuth } from './server/routes/study-auth.js'
|
||||
@@ -209,6 +209,14 @@ Promise.all([
|
||||
}, 60 * 60 * 1000)
|
||||
void scheduleWeeklyDigestEmails(sendStudyWeeklyDigestEmail)
|
||||
|
||||
// Check for a scheduled series transition every hour
|
||||
setInterval(() => {
|
||||
runScheduledSeriesTransition().catch(err => {
|
||||
console.error('[series-scheduler] error:', err)
|
||||
})
|
||||
}, 60 * 60 * 1000)
|
||||
void runScheduledSeriesTransition()
|
||||
|
||||
app.listen(PORT, () => {
|
||||
logResendEmailAlignmentWarnings()
|
||||
console.log(`Siteforge listening on http://localhost:${PORT}`)
|
||||
|
||||
@@ -53,6 +53,73 @@ function invokeWebhook(url, action) {
|
||||
.catch(err => ({ ok: false, message: err instanceof Error ? err.message : `${action} webhook failed.` }))
|
||||
}
|
||||
|
||||
// Applies a scheduled series transition if its triggersAt time has passed.
|
||||
// Called on a server interval (see server.js). Safe to call if no transition is set.
|
||||
export async function runScheduledSeriesTransition() {
|
||||
const content = state.cachedSiteContent
|
||||
if (!content) return
|
||||
|
||||
const sched = content.scheduledSeriesTransition
|
||||
if (!sched || !sched.triggersAt || !sched.newTitle?.trim()) return
|
||||
if (new Date(sched.triggersAt) > new Date()) return
|
||||
|
||||
console.log('[series-scheduler] Firing scheduled series transition to:', sched.newTitle)
|
||||
|
||||
const archivedBook = {
|
||||
id: Date.now().toString(36),
|
||||
title: content.seriesTitle ?? '',
|
||||
description: content.seriesDescription ?? '',
|
||||
imageUrl: content.seriesImageUrl ?? '',
|
||||
season: (sched.newSeason ?? 2) - 1 || 1,
|
||||
}
|
||||
|
||||
const startHerePatch = {}
|
||||
if (sched.newStartHereHeading?.trim()) startHerePatch.startHereHeading = sched.newStartHereHeading.trim()
|
||||
if (sched.newStartHereIntro?.trim()) startHerePatch.startHereIntro = sched.newStartHereIntro.trim()
|
||||
if (sched.newStartHereStep1Title?.trim()) startHerePatch.startHereStep1Title = sched.newStartHereStep1Title.trim()
|
||||
if (sched.newStartHereStep1Body?.trim()) startHerePatch.startHereStep1Body = sched.newStartHereStep1Body.trim()
|
||||
if (sched.newStartHereStep1Cta?.trim()) startHerePatch.startHereStep1Cta = sched.newStartHereStep1Cta.trim()
|
||||
if (sched.newStartHereStep2Title?.trim()) startHerePatch.startHereStep2Title = sched.newStartHereStep2Title.trim()
|
||||
if (sched.newStartHereStep2Body?.trim()) startHerePatch.startHereStep2Body = sched.newStartHereStep2Body.trim()
|
||||
if (sched.newStartHereStep2Cta?.trim()) startHerePatch.startHereStep2Cta = sched.newStartHereStep2Cta.trim()
|
||||
if (sched.newStartHereStep3Title?.trim()) startHerePatch.startHereStep3Title = sched.newStartHereStep3Title.trim()
|
||||
if (sched.newStartHereStep3Body?.trim()) startHerePatch.startHereStep3Body = sched.newStartHereStep3Body.trim()
|
||||
if (sched.newStartHereStep3Cta?.trim()) startHerePatch.startHereStep3Cta = sched.newStartHereStep3Cta.trim()
|
||||
|
||||
const studyGuidePatch = {}
|
||||
if (sched.newStudyGuideTitle?.trim()) studyGuidePatch.studyGuideTitle = sched.newStudyGuideTitle.trim()
|
||||
if (sched.newStudyGuideDescription?.trim()) studyGuidePatch.studyGuideDescription = sched.newStudyGuideDescription.trim()
|
||||
if (sched.newStudyGuideDownloadUrl?.trim()) studyGuidePatch.studyGuideDownloadUrl = sched.newStudyGuideDownloadUrl.trim()
|
||||
if (sched.newStudyGuideUrl?.trim()) studyGuidePatch.studyGuideUrl = sched.newStudyGuideUrl.trim()
|
||||
if (sched.newStudyGuideAmazonButtonLabel?.trim()) studyGuidePatch.studyGuideAmazonButtonLabel = sched.newStudyGuideAmazonButtonLabel.trim()
|
||||
if (sched.newStudyGuideAppleBooksUrl?.trim()) studyGuidePatch.studyGuideAppleBooksUrl = sched.newStudyGuideAppleBooksUrl.trim()
|
||||
if (sched.newStudyGuideAppleBooksLabel?.trim()) studyGuidePatch.studyGuideAppleBooksLabel = sched.newStudyGuideAppleBooksLabel.trim()
|
||||
|
||||
const updatedContent = sanitizeSiteContent({
|
||||
...content,
|
||||
seriesTitle: sched.newTitle.trim(),
|
||||
seriesDescription: sched.newDescription?.trim() ?? '',
|
||||
seriesImageUrl: sched.newImageUrl?.trim() ?? content.seriesImageUrl ?? '',
|
||||
seriesLabel: 'Now Playing',
|
||||
podcastFeaturedLinks: [],
|
||||
finishedBooks: [...(Array.isArray(content.finishedBooks) ? content.finishedBooks : []), archivedBook],
|
||||
...startHerePatch,
|
||||
...studyGuidePatch,
|
||||
scheduledSeriesTransition: undefined,
|
||||
})
|
||||
|
||||
const publishedAt = new Date().toISOString()
|
||||
try {
|
||||
await mkdir(DATA_DIR, { recursive: true })
|
||||
await writeFile(DATA_FILE, JSON.stringify({ siteContent: updatedContent, updatedAt: publishedAt }, null, 2), 'utf8')
|
||||
state.cachedSiteContent = updatedContent
|
||||
state.publishState.publishedAt = publishedAt
|
||||
console.log('[series-scheduler] Transition complete — archived:', archivedBook.title)
|
||||
} catch (err) {
|
||||
console.error('[series-scheduler] Failed to write transition:', err)
|
||||
}
|
||||
}
|
||||
|
||||
export function register(app) {
|
||||
app.get('/api/admin-content', async (req, res) => {
|
||||
const source = req.query?.source === 'draft' ? 'draft' : 'published'
|
||||
|
||||
@@ -4014,6 +4014,150 @@ async function handleAssetUpload(event: ChangeEvent<HTMLInputElement>) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schedule Auto-Transition */}
|
||||
{(() => {
|
||||
const sched = form.scheduledSeriesTransition
|
||||
const patch = (p: Partial<NonNullable<typeof sched>>) =>
|
||||
setForm(f => ({ ...f, scheduledSeriesTransition: { ...(f.scheduledSeriesTransition ?? { triggersAt: '', newTitle: '', newSeason: 2 }), ...p } }))
|
||||
return (
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<hr style={{ border: 'none', borderTop: '1px solid #2a2518', marginBottom: '1.5rem' }} />
|
||||
<h3 style={{ marginBottom: '0.35rem', fontSize: '1rem' }}>Schedule Auto Series Launch</h3>
|
||||
<p className="admin-stats-note" style={{ marginBottom: '1rem' }}>
|
||||
Set one date & time to automatically archive the current series and swap in new content for the Series card, Start Here section, and Companion Study Guide. The server checks every hour. Leave optional fields blank to keep the current values unchanged.
|
||||
</p>
|
||||
{sched?.triggersAt && (
|
||||
<div style={{ background: 'rgba(201,168,76,0.08)', border: '1px solid rgba(201,168,76,0.25)', borderRadius: '10px', padding: '0.8rem 1rem', marginBottom: '1rem' }}>
|
||||
<p style={{ margin: 0, fontSize: '0.88rem', color: '#c9a84c' }}>
|
||||
<strong>Scheduled:</strong> Launch “{sched.newTitle}” on{' '}
|
||||
{new Date(sched.triggersAt).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-triggers-at">Launch Date & Time</label>
|
||||
<input
|
||||
id="sched-triggers-at"
|
||||
type="datetime-local"
|
||||
value={sched?.triggersAt ? new Date(sched.triggersAt).toISOString().slice(0, 16) : ''}
|
||||
onChange={e => patch({ triggersAt: e.target.value ? new Date(e.target.value).toISOString() : '' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>New Series Card</p>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-new-title">Series Title</label>
|
||||
<input id="sched-new-title" type="text" placeholder="e.g. Study of Romans" value={sched?.newTitle ?? ''} onChange={e => patch({ newTitle: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-new-desc">Series Description</label>
|
||||
<textarea id="sched-new-desc" rows={3} placeholder="Brief description of the new series…" value={sched?.newDescription ?? ''} onChange={e => patch({ newDescription: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-new-image">Series Image URL</label>
|
||||
<input id="sched-new-image" type="text" placeholder="/images/romans-cover.png" value={sched?.newImageUrl ?? ''} onChange={e => patch({ newImageUrl: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-new-season">New Season Number</label>
|
||||
<input id="sched-new-season" type="number" min="1" placeholder="2" value={sched?.newSeason ?? ''} onChange={e => patch({ newSeason: parseInt(e.target.value) || 2 })} />
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>Start Here Section <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional)</span></p>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sh-heading">Heading</label>
|
||||
<input id="sched-sh-heading" type="text" placeholder="New Here? Start Here" value={sched?.newStartHereHeading ?? ''} onChange={e => patch({ newStartHereHeading: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sh-intro">Intro</label>
|
||||
<textarea id="sched-sh-intro" rows={2} placeholder="Intro paragraph…" value={sched?.newStartHereIntro ?? ''} onChange={e => patch({ newStartHereIntro: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sh-s1t">Step 1 Title</label>
|
||||
<input id="sched-sh-s1t" type="text" value={sched?.newStartHereStep1Title ?? ''} onChange={e => patch({ newStartHereStep1Title: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sh-s1b">Step 1 Body</label>
|
||||
<textarea id="sched-sh-s1b" rows={2} value={sched?.newStartHereStep1Body ?? ''} onChange={e => patch({ newStartHereStep1Body: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sh-s1c">Step 1 Button Text</label>
|
||||
<input id="sched-sh-s1c" type="text" value={sched?.newStartHereStep1Cta ?? ''} onChange={e => patch({ newStartHereStep1Cta: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sh-s2t">Step 2 Title</label>
|
||||
<input id="sched-sh-s2t" type="text" value={sched?.newStartHereStep2Title ?? ''} onChange={e => patch({ newStartHereStep2Title: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sh-s2b">Step 2 Body</label>
|
||||
<textarea id="sched-sh-s2b" rows={2} value={sched?.newStartHereStep2Body ?? ''} onChange={e => patch({ newStartHereStep2Body: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sh-s2c">Step 2 Button Text</label>
|
||||
<input id="sched-sh-s2c" type="text" value={sched?.newStartHereStep2Cta ?? ''} onChange={e => patch({ newStartHereStep2Cta: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sh-s3t">Step 3 Title</label>
|
||||
<input id="sched-sh-s3t" type="text" value={sched?.newStartHereStep3Title ?? ''} onChange={e => patch({ newStartHereStep3Title: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sh-s3b">Step 3 Body</label>
|
||||
<textarea id="sched-sh-s3b" rows={2} value={sched?.newStartHereStep3Body ?? ''} onChange={e => patch({ newStartHereStep3Body: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sh-s3c">Step 3 Button Text</label>
|
||||
<input id="sched-sh-s3c" type="text" value={sched?.newStartHereStep3Cta ?? ''} onChange={e => patch({ newStartHereStep3Cta: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '0.78rem', fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase', color: '#7a7060', margin: '1.25rem 0 0.6rem' }}>Companion Study Guide <span style={{ fontWeight: 400, textTransform: 'none' }}>(optional)</span></p>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sg-title">Guide Title</label>
|
||||
<input id="sched-sg-title" type="text" placeholder="Companion Study Guide" value={sched?.newStudyGuideTitle ?? ''} onChange={e => patch({ newStudyGuideTitle: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sg-desc">Guide Description</label>
|
||||
<textarea id="sched-sg-desc" rows={3} value={sched?.newStudyGuideDescription ?? ''} onChange={e => patch({ newStudyGuideDescription: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sg-download">Download URL</label>
|
||||
<input id="sched-sg-download" type="url" placeholder="/uploads/guide.pdf" value={sched?.newStudyGuideDownloadUrl ?? ''} onChange={e => patch({ newStudyGuideDownloadUrl: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sg-amazon">Amazon URL</label>
|
||||
<input id="sched-sg-amazon" type="url" placeholder="https://amazon.com/…" value={sched?.newStudyGuideUrl ?? ''} onChange={e => patch({ newStudyGuideUrl: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sg-amazon-label">Amazon Button Label</label>
|
||||
<input id="sched-sg-amazon-label" type="text" placeholder="Get it on Amazon" value={sched?.newStudyGuideAmazonButtonLabel ?? ''} onChange={e => patch({ newStudyGuideAmazonButtonLabel: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sg-apple-url">Apple Books URL</label>
|
||||
<input id="sched-sg-apple-url" type="url" placeholder="https://books.apple.com/…" value={sched?.newStudyGuideAppleBooksUrl ?? ''} onChange={e => patch({ newStudyGuideAppleBooksUrl: e.target.value })} />
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label htmlFor="sched-sg-apple-label">Apple Books Button Label</label>
|
||||
<input id="sched-sg-apple-label" type="text" placeholder="Get it on Apple Books" value={sched?.newStudyGuideAppleBooksLabel ?? ''} onChange={e => patch({ newStudyGuideAppleBooksLabel: e.target.value })} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary"
|
||||
disabled={!sched?.triggersAt || !sched?.newTitle?.trim()}
|
||||
onClick={() => handleSaveDraft()}
|
||||
>Save Schedule</button>
|
||||
{sched?.triggersAt && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-danger-outline"
|
||||
onClick={() => setForm(f => ({ ...f, scheduledSeriesTransition: undefined }))}
|
||||
>Clear Schedule</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</section>
|
||||
)}
|
||||
|
||||
|
||||
@@ -320,6 +320,37 @@ export interface SiteContent {
|
||||
episodesSeoIntro: string
|
||||
// ── Finished Books ──
|
||||
finishedBooks: FinishedBook[]
|
||||
// ── Scheduled Series Transition ──
|
||||
// All fields fire together at triggersAt. Start Here and Study Guide fields are optional —
|
||||
// omit them to leave those sections unchanged when the series transitions.
|
||||
scheduledSeriesTransition?: {
|
||||
triggersAt: string // ISO datetime — server fires at or after this time (checks hourly)
|
||||
// New series card
|
||||
newTitle: string
|
||||
newDescription?: string
|
||||
newImageUrl?: string
|
||||
newSeason: number
|
||||
// Start Here section (optional)
|
||||
newStartHereHeading?: string
|
||||
newStartHereIntro?: string
|
||||
newStartHereStep1Title?: string
|
||||
newStartHereStep1Body?: string
|
||||
newStartHereStep1Cta?: string
|
||||
newStartHereStep2Title?: string
|
||||
newStartHereStep2Body?: string
|
||||
newStartHereStep2Cta?: string
|
||||
newStartHereStep3Title?: string
|
||||
newStartHereStep3Body?: string
|
||||
newStartHereStep3Cta?: string
|
||||
// Companion Study Guide / Featured Download (optional)
|
||||
newStudyGuideTitle?: string
|
||||
newStudyGuideDescription?: string
|
||||
newStudyGuideDownloadUrl?: string
|
||||
newStudyGuideUrl?: string
|
||||
newStudyGuideAmazonButtonLabel?: string
|
||||
newStudyGuideAppleBooksUrl?: string
|
||||
newStudyGuideAppleBooksLabel?: string
|
||||
}
|
||||
// ── Testimonials ──
|
||||
testimonials: Testimonial[]
|
||||
// ── Nuggets (sub-show) ──
|
||||
|
||||
Reference in new issue
Block a user