gigafibre-fsm/apps/ops/src/api/campaigns.js
louispaulb 9f3a991442 feat(campaigns): delete campaign from the list
DELETE /campaigns/:id removes the JSON from /opt/targo-hub/data/campaigns/.
The Giftbit shortlinks already issued for that campaign live on Giftbit's
side and are unaffected — this is purely about clearing internal tracking
records (typically test runs cluttering the list).

Refuses (409) while the send worker is active for that id so we never
yank the file out from under saveCampaign(). Defensive id regex
(in campaignPath) blocks path-traversal attempts before unlink runs.

UI: red trash icon on each row, disabled while status=sending.
Confirmation dialog spells out what survives the deletion (Giftbit
links) vs what's lost (tracking, opens/clicks, CSV report) so the
operator isn't surprised.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 09:33:44 -04:00

174 lines
6.0 KiB
JavaScript

/**
* api/campaigns.js — Client for Hub /campaigns endpoints.
*
* Mirrors services/targo-hub/lib/campaigns.js. All gift-campaign requests
* go through the Hub which handles ERPNext auth + Mailjet send + SSE
* progress broadcast.
*
* Functions:
* parseCsvs({ map_csv, giftbit_csv, multi }) → preview matched send list
* createCampaign({ name, params, recipients }) → save + return id
* listCampaigns() → summaries
* getCampaign(id) → full detail
* updateCampaign(id, patch) → edit recipients/params
* sendCampaign(id) → fire background worker
* campaignSseUrl(id) → SSE URL for live updates
*/
import { HUB_URL } from 'src/config/hub'
async function hubFetch (path, { method = 'GET', body } = {}) {
const opts = { method, headers: { 'Content-Type': 'application/json' } }
if (body) opts.body = JSON.stringify(body)
const res = await fetch(`${HUB_URL}${path}`, opts)
const text = await res.text()
let data
try { data = text ? JSON.parse(text) : {} }
catch { throw new Error(`Invalid JSON from ${path}: ${text.slice(0, 200)}`) }
if (!res.ok) {
const msg = data.error || `HTTP ${res.status}`
const err = new Error(msg)
err.status = res.status
throw err
}
return data
}
export function parseCsvs ({ map_csv, giftbit_csv, multi = 'first' }) {
return hubFetch('/campaigns/parse', {
method: 'POST',
body: { map_csv, giftbit_csv, multi },
})
}
export function createCampaign ({ name, params, recipients }) {
return hubFetch('/campaigns', {
method: 'POST',
body: { name, params, recipients },
})
}
export function listCampaigns () {
return hubFetch('/campaigns').then(r => r.campaigns || [])
}
export function getCampaign (id) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}`)
}
export function updateCampaign (id, patch) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}`, {
method: 'PATCH',
body: patch,
})
}
export function sendCampaign (id) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}/send`, {
method: 'POST',
})
}
// Permanent deletion — removes the JSON on the hub. Used for clearing
// test campaigns from the list. Giftbit shortlinks are unaffected.
export function deleteCampaign (id) {
return hubFetch(`/campaigns/${encodeURIComponent(id)}`, {
method: 'DELETE',
})
}
// Build the URL the browser hits to download the per-recipient CSV report
// (giftbit shortlink ↔ email ↔ name ↔ status). The hub serves it with the
// proper Content-Disposition so an <a download> click triggers a save.
export function campaignReportCsvUrl (id) {
return `${HUB_URL}/campaigns/${encodeURIComponent(id)}/report.csv`
}
// ── Image assets (self-hosted on the hub, for GrapesJS asset manager) ───────
export function listAssets () {
return hubFetch('/campaigns/assets').then(r => r.assets || [])
}
// Upload a File / Blob from the browser via base64-encoded JSON. Bypasses
// multipart parsing on the hub side (zero new deps) at the cost of ~33%
// payload overhead. Acceptable for the ≤5 MB images we permit.
export async function uploadAsset (file) {
const dataUrl = await new Promise((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(r.result)
r.onerror = () => reject(new Error('FileReader failed'))
r.readAsDataURL(file)
})
return hubFetch('/campaigns/assets/upload', {
method: 'POST',
body: { name: file.name, data: dataUrl },
})
}
export function deleteAsset (filename) {
return hubFetch(`/campaigns/assets/${encodeURIComponent(filename)}`, {
method: 'DELETE',
})
}
// ── Template editing (used by the GrapesJS editor page) ─────────────────────
export function listTemplates () {
return hubFetch('/campaigns/templates').then(r => r.templates || [])
}
export function getTemplate (name) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`)
}
// saveTemplate(name, content, opts) — content is HTML by default.
// Optional opts.design = Unlayer design JSON (persisted alongside HTML so the
// editor can re-load the visual state on next open).
// Legacy opts.format = 'mjml' still supported for older callers (sends mjml).
export function saveTemplate (name, content, { format = 'html', design = null } = {}) {
const body = format === 'mjml' ? { mjml: content } : { html: content }
if (design) body.design = design
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}`, {
method: 'PUT',
body,
})
}
export function previewTemplate (name, { html, vars } = {}) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/preview`, {
method: 'POST',
body: { html, vars },
})
}
// Translate the source template to a target language via Gemini.
// targetName must match the source's prefix (e.g. gift-email-fr → gift-email-en).
// override=true required if the target already exists.
export function translateTemplate (srcName, targetName, { override = false } = {}) {
return hubFetch(
`/campaigns/templates/${encodeURIComponent(srcName)}/translate-to/${encodeURIComponent(targetName)}`,
{ method: 'POST', body: { override } },
)
}
// Send ONE rendered email to a specific address for visual QA.
// Pass { to, vars, from?, subject? } — defaults filled in server-side.
export function testSendTemplate (name, { to, vars, from, subject } = {}) {
return hubFetch(`/campaigns/templates/${encodeURIComponent(name)}/test-send`, {
method: 'POST',
body: { to, vars, from, subject },
})
}
/**
* Returns the URL for the SSE channel of one campaign. The Hub broadcasts on
* topic `campaign:<id>` so we subscribe to that single topic. Use with:
* const es = new EventSource(campaignSseUrl(id))
* es.addEventListener('recipient-update', ev => { ... })
* es.addEventListener('campaign-done', ev => { ... })
*/
export function campaignSseUrl (id) {
return `${HUB_URL}/sse?topics=campaign:${encodeURIComponent(id)}`
}