Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions server/public/admin-newsletter.html
Original file line number Diff line number Diff line change
Expand Up @@ -560,7 +560,7 @@ <h2>Add Custom Section</h2>
if (status === 'draft') {
return `
<div class="send-status-title">Draft only</div>
<div class="send-status-copy">This edition has not been queued. Send tests, finish edits, then approve it for the scheduled sender.</div>
<div class="send-status-copy">This edition has not been queued. Finish edits, then approve it for the scheduled sender or approve and send it immediately.</div>
<div class="send-status-meta">
<span class="send-status-chip">${esc(formatSendWindowChip())}</span>
${recipientCount ? `<span class="send-status-chip">${esc(String(recipientCount.emailCount))} eligible email recipients</span>` : ''}
Expand Down Expand Up @@ -701,6 +701,7 @@ <h2>Add Custom Section</h2>
<span class="status-badge status-${esc(status)}">${esc(status)}</span>
${status === 'draft' || status === 'approved' ? `<button class="btn btn-outline" onclick="sendTest()">Send test</button>` : ''}
${status === 'draft' ? `<button class="btn btn-success" onclick="approveEdition()">Approve for scheduled send</button>` : ''}
${status === 'draft' ? `<button class="btn btn-nl" onclick="sendNow(event)">Approve and send now</button>` : ''}
${status === 'approved' ? `<button class="btn btn-nl" onclick="sendNow(event)">Send now</button>` : ''}
</div>
${optInBar}
Expand Down Expand Up @@ -1157,8 +1158,11 @@ <h3><span class="item-badge badge-custom">CUSTOM</span> ${esc(cs.title || 'Untit

async function sendNow(event) {
const btn = event?.currentTarget;
const originalButtonText = btn?.textContent || 'Send now';
const count = recipientCount ? ` This will go to ${recipientCount.emailCount} recipients and post to Slack if configured.` : '';
if (!confirm('Send this approved edition now?' + count)) return;
const isDraft = currentDigest.status === 'draft';
const prompt = isDraft ? 'Approve this draft and send it now?' : 'Send this approved edition now?';
if (!confirm(prompt + count)) return;
try {
if (btn) { btn.disabled = true; btn.textContent = 'Sending...'; }
const res = await api('/editions/' + currentDigest.id + '/send-now', { method: 'POST' });
Expand All @@ -1173,7 +1177,7 @@ <h3><span class="item-badge badge-custom">CUSTOM</span> ${esc(cs.title || 'Untit
toast(Number.isFinite(sent) ? `Sent ${sent} emails` : 'Sent');
} catch (err) {
toast('Send failed: ' + esc(err.message));
if (btn) { btn.disabled = false; btn.textContent = 'Send now'; }
if (btn) { btn.disabled = false; btn.textContent = originalButtonText; }
}
}

Expand Down
25 changes: 24 additions & 1 deletion server/src/addie/jobs/weekly-digest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { WorkingGroupDatabase } from '../../db/working-group-db.js';
import { sendChannelMessage } from '../../slack/client.js';
import { sendTrackedBatchMarketingEmails, type TrackedBatchMarketingEmail } from '../../notifications/email.js';
import { renderDigestEmail, renderDigestSlack, renderDigestReview, type DigestSegment } from '../templates/weekly-digest.js';
import { withNewsletterSendLock } from '../../newsletters/send-lock.js';
import { publishDigestAsPerspective } from '../services/digest-publisher.js';
import { generateCoverForEdition } from '../../newsletters/cover.js';
import { markSuggestionsIncluded } from '../../db/newsletter-suggestions-db.js';
Expand Down Expand Up @@ -271,6 +272,25 @@ async function sendApprovedDigest(editionDate: string, etHour: number): Promise<
* Called from the scheduled job and from the approval handler (for late approvals).
*/
export async function sendDigest(digest: DigestRecord): Promise<{ sent: number }> {
const editionDate = new Date(digest.edition_date).toISOString().split('T')[0];
const locked = await withNewsletterSendLock('the_prompt', digest.id, async () => {
// Re-read under the cross-process lock so a stale approved record cannot
// be delivered after a manual sender has already completed it.
const current = await getDigestByDate(editionDate);
if (!current || current.id !== digest.id || current.status !== 'approved') {
return { sent: 0 };
}
return deliverDigest(current);
});

if (!locked.acquired) {
logger.warn({ digestId: digest.id, editionDate }, 'The Prompt delivery already in progress');
return { sent: 0 };
}
return locked.value;
}

async function deliverDigest(digest: DigestRecord): Promise<{ sent: number }> {
if (digest.status !== 'approved') {
logger.error({ digestId: digest.id, status: digest.status }, 'sendDigest called on non-approved digest');
return { sent: 0 };
Expand Down Expand Up @@ -331,7 +351,10 @@ export async function sendDigest(digest: DigestRecord): Promise<{ sent: number }

// Mark as sent
if (stats.email_count > 0 || stats.slack_count > 0) {
await markSent(digest.id, stats);
const markedSent = await markSent(digest.id, stats);
if (!markedSent) {
throw new Error(`Failed to finalize The Prompt edition ${digest.id} after delivery`);
}

// Publish as perspective for SEO/discoverability (non-blocking)
void (async () => {
Expand Down
22 changes: 19 additions & 3 deletions server/src/newsletters/admin-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,13 +322,29 @@ export function createNewsletterAdminRoutes(config: NewsletterConfig): Router {
const id = parseInt(req.params.id, 10);
if (isNaN(id)) return res.status(400).json({ error: 'Invalid edition ID' });

const edition = await config.db.getCurrent();
let edition = await config.db.getCurrent();
if (!edition || edition.id !== id) return res.status(404).json({ error: 'Edition not found' });
if (edition.status !== 'approved') {
return res.status(400).json({ error: 'Only approved editions can be sent. Approve the draft first.' });
if (edition.status === 'draft') {
const approvedBy = req.user?.email || 'admin';
const approved = await config.db.approve(id, approvedBy);
if (!approved) {
return res.status(409).json({ error: 'Edition status changed. Refresh and try again.' });
}
edition = approved;
} else if (edition.status !== 'approved') {
return res.status(400).json({ error: 'Only draft or approved editions can be sent.' });
}

const result = await sendNewsletter(config, edition);
if (result.outcome === 'busy') {
return res.status(409).json({ error: 'This edition is already being sent.' });
}
if (result.outcome === 'not_sendable') {
return res.status(409).json({ error: 'Edition status changed. Refresh and try again.' });
}
if (result.outcome === 'failed') {
return res.status(502).json({ error: 'No newsletter deliveries succeeded. The edition remains approved for review.' });
}
const updated = await config.db.getCurrent();
const digest = updated && updated.id === id ? updated : edition;
const subject = config.generateSubject(digest.content);
Expand Down
59 changes: 59 additions & 0 deletions server/src/newsletters/send-lock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type { PoolClient } from 'pg';
import { getPool } from '../db/client.js';
import { createLogger } from '../logger.js';

const logger = createLogger('newsletter-send-lock');

export type NewsletterSendLockResult<T> =
| { acquired: true; value: T }
| { acquired: false };

/**
* Serialize delivery for one newsletter edition across web and worker
* processes. The caller must re-read the edition while holding the lock so a
* stale approved snapshot cannot be delivered after another sender finishes.
*/
export async function withNewsletterSendLock<T>(
newsletterId: string,
editionId: number,
work: () => Promise<T>,
): Promise<NewsletterSendLockResult<T>> {
const client: PoolClient = await getPool().connect();
const lockKey = `newsletter-send:${newsletterId}:${editionId}`;
let acquired = false;
let destroyClient = false;

try {
const lockResult = await client.query<{ acquired: boolean }>(
'SELECT pg_try_advisory_lock(hashtextextended($1, 0)) AS acquired',
[lockKey],
);
acquired = lockResult.rows[0]?.acquired === true;
if (!acquired) return { acquired: false };

return { acquired: true, value: await work() };
} finally {
if (acquired) {
try {
const unlockResult = await client.query<{ unlocked: boolean }>(
'SELECT pg_advisory_unlock(hashtextextended($1, 0)) AS unlocked',
[lockKey],
);
if (unlockResult.rows[0]?.unlocked !== true) {
destroyClient = true;
logger.warn(
{ newsletterId, editionId },
'Newsletter send lock was not released; discarding pooled connection',
);
}
} catch (error) {
destroyClient = true;
logger.warn(
{ error, newsletterId, editionId },
'Failed to release newsletter send lock; discarding pooled connection',
);
}
}
client.release(destroyClient);
}
}
37 changes: 34 additions & 3 deletions server/src/newsletters/send-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { sendTrackedBatchMarketingEmails, type TrackedBatchMarketingEmail } from
import { proposeContentForUser, type ContentUser } from '../routes/content.js';
import { generateIllustration } from '../services/illustration-generator.js';
import { createIllustration, approveIllustration } from '../db/illustration-db.js';
import { withNewsletterSendLock } from './send-lock.js';

const logger = createLogger('newsletter-send');

Expand All @@ -23,7 +24,31 @@ const logger = createLogger('newsletter-send');
export async function sendNewsletter(
config: NewsletterConfig,
edition: EditionRecord,
): Promise<{ sent: number }> {
): Promise<{ sent: number; outcome: 'delivered' | 'busy' | 'not_sendable' | 'failed' }> {
const locked = await withNewsletterSendLock(config.id, edition.id, async () => {
// The edition passed by the caller may be stale by the time the lock is
// acquired. Re-read it under the lock and only deliver an approved row.
const current = await config.db.getCurrent();
if (!current || current.id !== edition.id || current.status !== 'approved') {
return { sent: 0, outcome: 'not_sendable' as const };
}
return deliverNewsletter(config, current);
});

if (!locked.acquired) {
logger.warn(
{ newsletterId: config.id, editionId: edition.id },
'Newsletter delivery already in progress',
);
return { sent: 0, outcome: 'busy' };
}
return locked.value;
}

async function deliverNewsletter(
config: NewsletterConfig,
edition: EditionRecord,
): Promise<{ sent: number; outcome: 'delivered' | 'failed' }> {
const content = edition.content;
const editionDate = edition.edition_date.toISOString().split('T')[0];
const subject = config.generateSubject(content);
Expand Down Expand Up @@ -73,7 +98,10 @@ export async function sendNewsletter(

// Mark as sent
if (stats.email_count > 0 || stats.slack_count > 0) {
await config.db.markSent(edition.id, stats);
const markedSent = await config.db.markSent(edition.id, stats);
if (!markedSent) {
throw new Error(`Failed to finalize ${config.id} edition ${edition.id} after delivery`);
}

// Publish as perspective (non-blocking)
publishAsPerspective(config, edition.id, content, editionDate, subject).catch((err) => {
Expand All @@ -96,7 +124,10 @@ export async function sendNewsletter(
});
}

return { sent: stats.email_count };
return {
sent: stats.email_count,
outcome: stats.email_count > 0 || stats.slack_count > 0 ? 'delivered' : 'failed',
};
}

// ─── Perspective Publishing ────────────────────────────────────────────
Expand Down
151 changes: 151 additions & 0 deletions server/tests/unit/newsletter-admin-send-now.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { readFile } from 'node:fs/promises';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import express from 'express';
import request from 'supertest';
import type { EditionRecord, NewsletterConfig } from '../../src/newsletters/config.js';

const mocks = vi.hoisted(() => ({
sendNewsletter: vi.fn(),
}));

vi.mock('../../src/middleware/auth.js', () => ({
requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => {
req.user = { id: 'admin_01', email: 'admin@example.test', is_admin: true } as typeof req.user;
next();
},
requireAdmin: (_req: express.Request, _res: express.Response, next: express.NextFunction) => next(),
}));

vi.mock('../../src/newsletters/send-pipeline.js', () => ({
sendNewsletter: mocks.sendNewsletter,
}));

vi.mock('../../src/db/client.js', () => ({
query: vi.fn(),
}));

function edition(status: EditionRecord['status']): EditionRecord {
return {
id: 42,
edition_date: new Date('2026-08-18T00:00:00.000Z'),
status,
content: { emailSubject: 'Test edition' },
approved_by: status === 'draft' ? null : 'admin@example.test',
approved_at: status === 'draft' ? null : new Date('2026-08-18T12:00:00.000Z'),
review_channel_id: null,
review_message_ts: null,
perspective_id: null,
created_at: new Date('2026-08-18T10:00:00.000Z'),
sent_at: status === 'sent' ? new Date('2026-08-18T12:05:00.000Z') : null,
send_stats: null,
};
}

function makeConfig(current: EditionRecord) {
const approved = edition('approved');
const sent = edition('sent');
const db = {
getCurrent: vi.fn()
.mockResolvedValueOnce(current)
.mockResolvedValueOnce(sent),
approve: vi.fn().mockResolvedValue(approved),
};
const config = {
id: 'the_prompt',
name: 'The Prompt',
author: 'Addie',
palette: { primary: '#000', light: '#fff', dark: '#111' },
editableFields: [],
cadence: { generateHourET: 8, sendHourET: 10, shouldRunToday: () => true },
sections: [],
db,
generateSubject: () => 'Test edition',
} as unknown as NewsletterConfig;
return { config, db, approved };
}

async function makeApp(config: NewsletterConfig) {
const { createNewsletterAdminRoutes } = await import('../../src/newsletters/admin-routes.js');
const app = express();
app.use(express.json());
app.use('/api/admin/newsletters/the_prompt', createNewsletterAdminRoutes(config));
return app;
}

describe('newsletter admin send now', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.sendNewsletter.mockResolvedValue({ sent: 12, outcome: 'delivered' });
});

it('approves a draft before sending it immediately', async () => {
const { config, db, approved } = makeConfig(edition('draft'));
const app = await makeApp(config);

const response = await request(app)
.post('/api/admin/newsletters/the_prompt/editions/42/send-now');

expect(response.status).toBe(200);
expect(db.approve).toHaveBeenCalledWith(42, 'admin@example.test');
expect(mocks.sendNewsletter).toHaveBeenCalledWith(config, approved);
expect(response.body.result).toEqual({ sent: 12, outcome: 'delivered' });
});

it('sends an already approved edition without approving it again', async () => {
const current = edition('approved');
const { config, db } = makeConfig(current);
const app = await makeApp(config);

const response = await request(app)
.post('/api/admin/newsletters/the_prompt/editions/42/send-now');

expect(response.status).toBe(200);
expect(db.approve).not.toHaveBeenCalled();
expect(mocks.sendNewsletter).toHaveBeenCalledWith(config, current);
});

it('does not send when draft approval loses a status race', async () => {
const { config, db } = makeConfig(edition('draft'));
db.approve.mockResolvedValueOnce(null);
const app = await makeApp(config);

const response = await request(app)
.post('/api/admin/newsletters/the_prompt/editions/42/send-now');

expect(response.status).toBe(409);
expect(response.body.error).toContain('status changed');
expect(mocks.sendNewsletter).not.toHaveBeenCalled();
});

it('does not resend a completed edition', async () => {
const { config, db } = makeConfig(edition('sent'));
const app = await makeApp(config);

const response = await request(app)
.post('/api/admin/newsletters/the_prompt/editions/42/send-now');

expect(response.status).toBe(400);
expect(db.approve).not.toHaveBeenCalled();
expect(mocks.sendNewsletter).not.toHaveBeenCalled();
});

it('reports a conflict when another sender holds the edition lock', async () => {
const { config } = makeConfig(edition('approved'));
mocks.sendNewsletter.mockResolvedValueOnce({ sent: 0, outcome: 'busy' });
const app = await makeApp(config);

const response = await request(app)
.post('/api/admin/newsletters/the_prompt/editions/42/send-now');

expect(response.status).toBe(409);
expect(response.body.error).toContain('already being sent');
});

it('shows the immediate-send action while the edition is still a draft', async () => {
const html = await readFile(new URL('../../public/admin-newsletter.html', import.meta.url), 'utf8');

expect(html).toContain("status === 'draft' ? `<button class=\"btn btn-nl\" onclick=\"sendNow(event)\">Approve and send now</button>` : ''");
expect(html).toContain('Approve this draft and send it now?');
expect(html).toContain('btn.textContent = originalButtonText');
});
});
Loading
Loading