feat(rsocials): newsletter editor with Listmonk integration

Full campaign editor with HTML body textarea, live iframe preview,
list selector with subscriber counts, save draft, send now, and
schedule send. Added edit/delete actions on draft campaigns and
GET/PUT/DELETE single-campaign proxy routes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-20 15:53:35 -07:00
parent 16b975cf5a
commit df489d698c
3 changed files with 385 additions and 40 deletions

View File

@ -4,6 +4,7 @@
* Attributes: space, role * Attributes: space, role
* Uses EncryptID access token for auth headers. * Uses EncryptID access token for auth headers.
* Three tabs: Lists, Subscribers, Campaigns. * Three tabs: Lists, Subscribers, Campaigns.
* Full campaign editor with list selector, HTML body, live preview, schedule/send.
*/ */
import { getAccessToken } from '../../../shared/components/rstack-identity'; import { getAccessToken } from '../../../shared/components/rstack-identity';
@ -26,6 +27,12 @@ export class FolkNewsletterManager extends HTMLElement {
private _campaigns: any[] = []; private _campaigns: any[] = [];
private _showCreateForm = false; private _showCreateForm = false;
// Editor state
private _editingCampaign: any | null = null;
private _selectedListIds: number[] = [];
private _previewHtml = '';
private _previewTimer: any = null;
static get observedAttributes() { return ['space', 'role']; } static get observedAttributes() { return ['space', 'role']; }
connectedCallback() { connectedCallback() {
@ -35,7 +42,6 @@ export class FolkNewsletterManager extends HTMLElement {
this.render(); this.render();
this.checkStatus(); this.checkStatus();
// Re-check status when module is configured inline
this.addEventListener('module-configured', () => { this.addEventListener('module-configured', () => {
this._configured = false; this._configured = false;
this._loading = true; this._loading = true;
@ -113,6 +119,8 @@ export class FolkNewsletterManager extends HTMLElement {
this._campaigns = data.data?.results || data.results || []; this._campaigns = data.data?.results || data.results || [];
} }
// ── Campaign CRUD ──
private async createCampaign(form: HTMLFormElement) { private async createCampaign(form: HTMLFormElement) {
const fd = new FormData(form); const fd = new FormData(form);
const body = JSON.stringify({ const body = JSON.stringify({
@ -133,10 +141,12 @@ export class FolkNewsletterManager extends HTMLElement {
await this.loadCampaigns(); await this.loadCampaigns();
} }
private async setCampaignStatus(id: number, status: string) { private async setCampaignStatus(id: number, status: string, sendAt?: string) {
const payload: any = { status };
if (sendAt) payload.send_at = sendAt;
const res = await this.apiFetch(`/campaigns/${id}/status`, { const res = await this.apiFetch(`/campaigns/${id}/status`, {
method: 'PUT', method: 'PUT',
body: JSON.stringify({ status }), body: JSON.stringify(payload),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({})); const err = await res.json().catch(() => ({}));
@ -148,6 +158,139 @@ export class FolkNewsletterManager extends HTMLElement {
this.render(); this.render();
} }
private async openEditor(campaignId?: number) {
if (campaignId) {
this._loading = true;
this.render();
try {
const res = await this.apiFetch(`/campaigns/${campaignId}`);
if (!res.ok) throw new Error('Failed to load campaign');
const data = await res.json();
this._editingCampaign = data.data || data;
this._selectedListIds = (this._editingCampaign.lists || []).map((l: any) => l.id);
this._previewHtml = this._editingCampaign.body || '';
} catch (e: any) {
this._error = e.message;
}
this._loading = false;
} else {
this._editingCampaign = { name: '', subject: '', body: '', lists: [] };
this._selectedListIds = [];
this._previewHtml = '';
}
// Ensure lists are loaded for the selector
if (this._lists.length === 0) {
try { await this.loadLists(); } catch {}
}
this.render();
}
private closeEditor() {
this._editingCampaign = null;
this._selectedListIds = [];
this._previewHtml = '';
this.loadCampaigns();
}
private async saveDraft() {
const root = this.shadowRoot!;
const name = (root.querySelector('[data-field="name"]') as HTMLInputElement)?.value?.trim();
const subject = (root.querySelector('[data-field="subject"]') as HTMLInputElement)?.value?.trim();
const body = (root.querySelector('[data-field="body"]') as HTMLTextAreaElement)?.value || '';
if (!name || !subject) {
this._error = 'Name and subject are required';
this.render();
return;
}
const payload: any = {
name,
subject,
body,
content_type: 'richtext',
type: 'regular',
lists: this._selectedListIds,
};
try {
let res: Response;
if (this._editingCampaign?.id) {
res = await this.apiFetch(`/campaigns/${this._editingCampaign.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
});
} else {
res = await this.apiFetch('/campaigns', {
method: 'POST',
body: JSON.stringify(payload),
});
}
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || err.error || 'Failed to save');
}
const data = await res.json();
// Update editing campaign with server response (gets ID for new campaigns)
this._editingCampaign = data.data || data;
this._error = '';
this.render();
} catch (e: any) {
this._error = e.message;
this.render();
}
}
private async deleteCampaign(id: number) {
if (!confirm('Delete this campaign? This cannot be undone.')) return;
try {
const res = await this.apiFetch(`/campaigns/${id}`, { method: 'DELETE' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || err.error || 'Failed to delete');
}
// If we were editing this campaign, close editor
if (this._editingCampaign?.id === id) {
this._editingCampaign = null;
}
await this.loadCampaigns();
this.render();
} catch (e: any) {
this._error = e.message;
this.render();
}
}
private async sendNow() {
if (!this._editingCampaign?.id) {
this._error = 'Save the campaign first';
this.render();
return;
}
if (!confirm('Send this campaign now to the selected lists? This action cannot be undone.')) return;
await this.saveDraft();
await this.setCampaignStatus(this._editingCampaign.id, 'running');
this.closeEditor();
}
private async scheduleSend() {
if (!this._editingCampaign?.id) {
this._error = 'Save the campaign first';
this.render();
return;
}
const input = this.shadowRoot!.querySelector('[data-field="schedule"]') as HTMLInputElement;
const sendAt = input?.value;
if (!sendAt) {
this._error = 'Pick a date and time to schedule';
this.render();
return;
}
await this.saveDraft();
await this.setCampaignStatus(this._editingCampaign.id, 'scheduled', new Date(sendAt).toISOString());
this.closeEditor();
}
private get isAdmin(): boolean { private get isAdmin(): boolean {
return this._role === 'admin'; return this._role === 'admin';
} }
@ -169,6 +312,11 @@ export class FolkNewsletterManager extends HTMLElement {
return this.renderSetup(); return this.renderSetup();
} }
// Editor mode replaces normal view
if (this._editingCampaign) {
return this.renderEditor();
}
return ` return `
<div class="nl-header"> <div class="nl-header">
<h2>Newsletter Manager</h2> <h2>Newsletter Manager</h2>
@ -197,18 +345,22 @@ export class FolkNewsletterManager extends HTMLElement {
private renderLists(): string { private renderLists(): string {
if (this._lists.length === 0) return `<div class="nl-empty">No mailing lists found</div>`; if (this._lists.length === 0) return `<div class="nl-empty">No mailing lists found</div>`;
const rows = this._lists.map(l => ` const rows = this._lists.map(l => {
const optinLabel = l.optin === 'double' ? 'double' : 'single';
return `
<tr> <tr>
<td>${this.esc(l.name)}</td> <td>${this.esc(l.name)}</td>
<td><span class="nl-badge nl-badge--${l.type === 'public' ? 'active' : 'draft'}">${this.esc(l.type)}</span></td> <td><span class="nl-badge nl-badge--${l.type === 'public' ? 'active' : 'draft'}">${this.esc(l.type)}</span></td>
<td>${l.subscriber_count ?? '—'}</td> <td><span class="nl-badge nl-badge--enabled">${l.subscriber_count ?? 0}</span></td>
<td><span class="nl-badge nl-badge--${optinLabel === 'double' ? 'active' : 'draft'}">${optinLabel} opt-in</span></td>
<td>${l.created_at ? new Date(l.created_at).toLocaleDateString() : '—'}</td> <td>${l.created_at ? new Date(l.created_at).toLocaleDateString() : '—'}</td>
</tr> </tr>
`).join(''); `;
}).join('');
return ` return `
<table class="nl-table"> <table class="nl-table">
<thead><tr><th>Name</th><th>Type</th><th>Subscribers</th><th>Created</th></tr></thead> <thead><tr><th>Name</th><th>Type</th><th>Subscribers</th><th>Opt-in</th><th>Created</th></tr></thead>
<tbody>${rows}</tbody> <tbody>${rows}</tbody>
</table> </table>
`; `;
@ -249,32 +401,11 @@ export class FolkNewsletterManager extends HTMLElement {
const createBtn = this.isAdmin ? ` const createBtn = this.isAdmin ? `
<div class="nl-toolbar"> <div class="nl-toolbar">
<span></span> <span></span>
<button class="nl-btn nl-btn--primary" data-action="toggle-create">+ New Campaign</button> <button class="nl-btn nl-btn--primary" data-action="new-campaign">+ New Campaign</button>
</div> </div>
` : ''; ` : '';
const form = this._showCreateForm && this.isAdmin ? ` if (this._campaigns.length === 0) {
<form class="nl-form" data-form="create-campaign">
<div class="nl-form-row">
<div>
<label>Campaign Name</label>
<input name="name" required placeholder="My Newsletter #1" />
</div>
<div>
<label>Subject Line</label>
<input name="subject" required placeholder="This week's update" />
</div>
</div>
<label>Body (HTML)</label>
<textarea name="body" placeholder="<p>Newsletter content goes here...</p>"></textarea>
<div style="display:flex;gap:.5rem;justify-content:flex-end;margin-top:.5rem;">
<button type="button" class="nl-btn" data-action="toggle-create">Cancel</button>
<button type="submit" class="nl-btn nl-btn--primary">Create Campaign</button>
</div>
</form>
` : '';
if (this._campaigns.length === 0 && !this._showCreateForm) {
return `${createBtn}<div class="nl-empty">No campaigns found</div>`; return `${createBtn}<div class="nl-empty">No campaigns found</div>`;
} }
@ -284,36 +415,106 @@ export class FolkNewsletterManager extends HTMLElement {
}; };
const rows = this._campaigns.map(c => { const rows = this._campaigns.map(c => {
const listNames = (c.lists || []).map((l: any) => this.esc(l.name)).join(', ') || '—';
const actions: string[] = []; const actions: string[] = [];
if (c.status === 'draft' && this.isAdmin) { if (c.status === 'draft' && this.isAdmin) {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="edit-campaign" data-id="${c.id}">Edit</button>`);
actions.push(`<button class="nl-btn nl-btn--sm" data-action="start-campaign" data-id="${c.id}">Start</button>`); actions.push(`<button class="nl-btn nl-btn--sm" data-action="start-campaign" data-id="${c.id}">Start</button>`);
actions.push(`<button class="nl-btn nl-btn--sm nl-btn--danger" data-action="delete-campaign" data-id="${c.id}">Delete</button>`);
} else if (c.status === 'running') { } else if (c.status === 'running') {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="pause-campaign" data-id="${c.id}">Pause</button>`); actions.push(`<button class="nl-btn nl-btn--sm" data-action="pause-campaign" data-id="${c.id}">Pause</button>`);
} else if (c.status === 'paused') { } else if (c.status === 'paused') {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="resume-campaign" data-id="${c.id}">Resume</button>`); actions.push(`<button class="nl-btn nl-btn--sm" data-action="resume-campaign" data-id="${c.id}">Resume</button>`);
} else if (c.status === 'scheduled' && this.isAdmin) {
actions.push(`<button class="nl-btn nl-btn--sm" data-action="edit-campaign" data-id="${c.id}">Edit</button>`);
} }
return ` return `
<tr> <tr>
<td>${this.esc(c.name)}</td> <td>${this.esc(c.name)}</td>
<td>${this.esc(c.subject || '—')}</td> <td>${this.esc(c.subject || '—')}</td>
<td><span class="nl-badge nl-badge--${statusLabel(c.status)}">${this.esc(c.status)}</span></td> <td><span class="nl-badge nl-badge--${statusLabel(c.status)}">${this.esc(c.status)}</span></td>
<td>${listNames}</td>
<td>${c.sent ?? '—'}</td> <td>${c.sent ?? '—'}</td>
<td>${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'}</td> <td>${c.created_at ? new Date(c.created_at).toLocaleDateString() : '—'}</td>
<td>${actions.join(' ')}</td> <td class="nl-actions-cell">${actions.join(' ')}</td>
</tr> </tr>
`; `;
}).join(''); }).join('');
return ` return `
${createBtn} ${createBtn}
${form}
<table class="nl-table"> <table class="nl-table">
<thead><tr><th>Name</th><th>Subject</th><th>Status</th><th>Sent</th><th>Created</th><th>Actions</th></tr></thead> <thead><tr><th>Name</th><th>Subject</th><th>Status</th><th>Lists</th><th>Sent</th><th>Created</th><th>Actions</th></tr></thead>
<tbody>${rows}</tbody> <tbody>${rows}</tbody>
</table> </table>
`; `;
} }
private renderEditor(): string {
const c = this._editingCampaign;
const isNew = !c.id;
const title = isNew ? 'New Campaign' : `Edit: ${this.esc(c.name)}`;
const listCheckboxes = this._lists.map(l => {
const checked = this._selectedListIds.includes(l.id) ? 'checked' : '';
return `
<label class="nl-list-option">
<input type="checkbox" data-list-id="${l.id}" ${checked} />
<span>${this.esc(l.name)}</span>
<span class="nl-list-count">${l.subscriber_count ?? 0}</span>
</label>
`;
}).join('');
return `
<div class="nl-editor-header">
<button class="nl-btn" data-action="close-editor">&larr; Back</button>
<h2>${title}</h2>
</div>
${this._error ? `<div class="nl-error">${this.esc(this._error)}</div>` : ''}
<div class="nl-editor-fields">
<div class="nl-form-row">
<div>
<label>Campaign Name</label>
<input data-field="name" value="${this.escAttr(c.name || '')}" placeholder="My Newsletter #1" />
</div>
<div>
<label>Subject Line</label>
<input data-field="subject" value="${this.escAttr(c.subject || '')}" placeholder="This week's update" />
</div>
</div>
<label>Target Lists</label>
<div class="nl-list-selector">
${listCheckboxes || '<span class="nl-empty">No lists available</span>'}
</div>
</div>
<div class="nl-editor">
<div class="nl-editor__body">
<label>Body (HTML)</label>
<textarea data-field="body" placeholder="<p>Newsletter content goes here...</p>">${this.esc(c.body || '')}</textarea>
</div>
<div class="nl-editor__preview">
<label>Preview</label>
<iframe class="nl-preview-frame" sandbox="allow-same-origin" srcdoc="${this.escAttr(this._previewHtml || c.body || '')}"></iframe>
</div>
</div>
<div class="nl-editor-actions">
<div class="nl-editor-actions__left">
<button class="nl-btn" data-action="close-editor">Cancel</button>
${c.id ? `<button class="nl-btn nl-btn--danger" data-action="delete-campaign" data-id="${c.id}">Delete</button>` : ''}
</div>
<div class="nl-editor-actions__right">
<button class="nl-btn nl-btn--primary" data-action="save-draft">Save Draft</button>
<div class="nl-schedule-group">
<input type="datetime-local" data-field="schedule" />
<button class="nl-btn" data-action="schedule-send">Schedule</button>
</div>
<button class="nl-btn nl-btn--send" data-action="send-now">Send Now</button>
</div>
</div>
`;
}
// ── Event listeners ── // ── Event listeners ──
private attachListeners() { private attachListeners() {
@ -336,7 +537,12 @@ export class FolkNewsletterManager extends HTMLElement {
this.loadTab(); this.loadTab();
}); });
// Create campaign toggle // New campaign (opens editor)
root.querySelector('[data-action="new-campaign"]')?.addEventListener('click', () => {
this.openEditor();
});
// Create campaign toggle (legacy, kept for form)
root.querySelectorAll('[data-action="toggle-create"]').forEach(btn => { root.querySelectorAll('[data-action="toggle-create"]').forEach(btn => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
this._showCreateForm = !this._showCreateForm; this._showCreateForm = !this._showCreateForm;
@ -344,7 +550,7 @@ export class FolkNewsletterManager extends HTMLElement {
}); });
}); });
// Create campaign form // Create campaign form (legacy)
const form = root.querySelector('[data-form="create-campaign"]') as HTMLFormElement | null; const form = root.querySelector('[data-form="create-campaign"]') as HTMLFormElement | null;
form?.addEventListener('submit', async (e) => { form?.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
@ -367,6 +573,47 @@ export class FolkNewsletterManager extends HTMLElement {
root.querySelectorAll('[data-action="resume-campaign"]').forEach(btn => { root.querySelectorAll('[data-action="resume-campaign"]').forEach(btn => {
btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running')); btn.addEventListener('click', () => this.setCampaignStatus(Number((btn as HTMLElement).dataset.id), 'running'));
}); });
// Edit campaign
root.querySelectorAll('[data-action="edit-campaign"]').forEach(btn => {
btn.addEventListener('click', () => this.openEditor(Number((btn as HTMLElement).dataset.id)));
});
// Delete campaign
root.querySelectorAll('[data-action="delete-campaign"]').forEach(btn => {
btn.addEventListener('click', () => this.deleteCampaign(Number((btn as HTMLElement).dataset.id)));
});
// Editor controls
root.querySelectorAll('[data-action="close-editor"]').forEach(btn => {
btn.addEventListener('click', () => this.closeEditor());
});
root.querySelector('[data-action="save-draft"]')?.addEventListener('click', () => this.saveDraft());
root.querySelector('[data-action="send-now"]')?.addEventListener('click', () => this.sendNow());
root.querySelector('[data-action="schedule-send"]')?.addEventListener('click', () => this.scheduleSend());
// List selector checkboxes
root.querySelectorAll('[data-list-id]').forEach(cb => {
cb.addEventListener('change', () => {
const id = Number((cb as HTMLElement).dataset.listId);
if ((cb as HTMLInputElement).checked) {
if (!this._selectedListIds.includes(id)) this._selectedListIds.push(id);
} else {
this._selectedListIds = this._selectedListIds.filter(x => x !== id);
}
});
});
// Live preview update on body textarea input
const bodyTextarea = root.querySelector('[data-field="body"]') as HTMLTextAreaElement | null;
bodyTextarea?.addEventListener('input', () => {
clearTimeout(this._previewTimer);
this._previewTimer = setTimeout(() => {
this._previewHtml = bodyTextarea.value;
const iframe = root.querySelector('.nl-preview-frame') as HTMLIFrameElement | null;
if (iframe) iframe.srcdoc = this._previewHtml;
}, 300);
});
} }
private esc(s: string): string { private esc(s: string): string {
@ -374,6 +621,10 @@ export class FolkNewsletterManager extends HTMLElement {
d.textContent = s; d.textContent = s;
return d.innerHTML; return d.innerHTML;
} }
private escAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
} }
customElements.define('folk-newsletter-manager', FolkNewsletterManager); customElements.define('folk-newsletter-manager', FolkNewsletterManager);

View File

@ -60,5 +60,54 @@
.nl-form-row { display: flex; gap: .75rem; } .nl-form-row { display: flex; gap: .75rem; }
.nl-form-row > * { flex: 1; } .nl-form-row > * { flex: 1; }
/* Danger button */
.nl-btn--danger { border-color: #ef4444; color: #f87171; }
.nl-btn--danger:hover { background: #ef444422; border-color: #f87171; }
.nl-btn--send { background: #6366f1; border-color: #6366f1; color: #fff; font-weight: 500; }
.nl-btn--send:hover { background: #4f46e5; }
/* Actions cell */
.nl-actions-cell { white-space: nowrap; }
.nl-actions-cell .nl-btn { margin-right: .25rem; }
/* Error */ /* Error */
.nl-error { padding: .75rem 1rem; background: #ef444422; border: 1px solid #ef4444; border-radius: 6px; color: #f87171; font-size: .85rem; margin-bottom: 1rem; } .nl-error { padding: .75rem 1rem; background: #ef444422; border: 1px solid #ef4444; border-radius: 6px; color: #f87171; font-size: .85rem; margin-bottom: 1rem; }
/* ── Editor ── */
.nl-editor-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.nl-editor-header h2 { font-size: 1.3rem; margin: 0; }
.nl-editor-fields { margin-bottom: 1rem; }
.nl-editor-fields label { display: block; font-size: .8rem; color: #a3a3a3; margin-bottom: .25rem; }
.nl-editor-fields input { width: 100%; padding: .45rem .7rem; background: #0a0a0a; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: .85rem; margin-bottom: .75rem; box-sizing: border-box; }
/* List selector */
.nl-list-selector { display: flex; flex-wrap: wrap; gap: .5rem; padding: .5rem; background: #1e1e2e; border: 1px solid #333; border-radius: 6px; margin-bottom: 1rem; min-height: 2.5rem; align-items: center; }
.nl-list-option { display: flex; align-items: center; gap: .4rem; padding: .3rem .6rem; background: #0a0a0a; border: 1px solid #333; border-radius: 4px; cursor: pointer; font-size: .82rem; transition: border-color .15s; }
.nl-list-option:hover { border-color: #14b8a6; }
.nl-list-option input[type="checkbox"] { accent-color: #14b8a6; }
.nl-list-count { color: #a3a3a3; font-size: .75rem; margin-left: .25rem; }
/* Editor body + preview */
.nl-editor { display: flex; gap: 1rem; margin-bottom: 1rem; }
.nl-editor__body { flex: 1; display: flex; flex-direction: column; }
.nl-editor__body label { font-size: .8rem; color: #a3a3a3; margin-bottom: .25rem; }
.nl-editor__body textarea { flex: 1; min-height: 300px; padding: .6rem .8rem; background: #0a0a0a; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: .85rem; font-family: 'Fira Code', 'Cascadia Code', monospace; resize: vertical; box-sizing: border-box; line-height: 1.5; }
.nl-editor__preview { flex: 1; display: flex; flex-direction: column; }
.nl-editor__preview label { font-size: .8rem; color: #a3a3a3; margin-bottom: .25rem; }
.nl-preview-frame { flex: 1; min-height: 300px; border: 1px solid #404040; border-radius: 4px; background: #fff; }
/* Editor action bar */
.nl-editor-actions { display: flex; justify-content: space-between; align-items: center; gap: .75rem; flex-wrap: wrap; padding-top: .75rem; border-top: 1px solid #333; }
.nl-editor-actions__left, .nl-editor-actions__right { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
.nl-schedule-group { display: flex; align-items: center; gap: .35rem; }
.nl-schedule-group input[type="datetime-local"] { padding: .4rem .5rem; background: #0a0a0a; border: 1px solid #404040; border-radius: 4px; color: #e5e5e5; font-size: .82rem; }
/* Responsive: stack editor panes vertically on narrow screens */
@media (max-width: 700px) {
.nl-editor { flex-direction: column; }
.nl-editor-actions { flex-direction: column; align-items: stretch; }
.nl-editor-actions__left, .nl-editor-actions__right { justify-content: center; }
.nl-form-row { flex-direction: column; }
.nl-schedule-group { flex-wrap: wrap; }
}

View File

@ -517,6 +517,51 @@ routes.put("/api/newsletter/campaigns/:id/status", async (c) => {
return c.json(data, res.status as any); return c.json(data, res.status as any);
}); });
// Single campaign CRUD
routes.get("/api/newsletter/campaigns/:id", async (c) => {
const auth = await requireNewsletterRole(c, "moderator");
if (auth instanceof Response) return auth;
const space = c.req.param("space") || "demo";
const config = await getListmonkConfig(space);
if (!config) return c.json({ error: "Listmonk not configured" }, 404);
const campaignId = c.req.param("id");
const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`);
const data = await res.json();
return c.json(data, res.status as any);
});
routes.put("/api/newsletter/campaigns/:id", async (c) => {
const auth = await requireNewsletterRole(c, "admin");
if (auth instanceof Response) return auth;
const space = c.req.param("space") || "demo";
const config = await getListmonkConfig(space);
if (!config) return c.json({ error: "Listmonk not configured" }, 404);
const campaignId = c.req.param("id");
const body = await c.req.text();
const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`, { method: "PUT", body });
const data = await res.json();
return c.json(data, res.status as any);
});
routes.delete("/api/newsletter/campaigns/:id", async (c) => {
const auth = await requireNewsletterRole(c, "admin");
if (auth instanceof Response) return auth;
const space = c.req.param("space") || "demo";
const config = await getListmonkConfig(space);
if (!config) return c.json({ error: "Listmonk not configured" }, 404);
const campaignId = c.req.param("id");
const res = await listmonkFetch(config, `/api/campaigns/${campaignId}`, { method: "DELETE" });
if (res.status === 200 || res.status === 204) return c.json({ ok: true });
const data = await res.json().catch(() => ({}));
return c.json(data, res.status as any);
});
// ── Postiz API proxy routes ── // ── Postiz API proxy routes ──
routes.get("/api/postiz/status", async (c) => { routes.get("/api/postiz/status", async (c) => {