Campaigns: archive a finished one, get the credits back, keep the numbers

A member asked for a delete button. Deleting would orphan the credit ledger,
the hourly view rows and the P&L, and throw away the figures they actually
want, so a campaign is archived instead: out of the working table, listed in
full underneath with its views, clicks and spend, and restorable.

The credits are the real point. Reserved budget is computed from campaigns
that are active or paused, so a paused campaign quietly holds credits the
member cannot spend anywhere else. Archiving releases them the moment the
status changes, with nothing to refund and nothing that can double-refund.

Only a paused or finished campaign can be archived, so pause stays the
deliberate first step, and restoring brings it back paused rather than live.
The syndication rails are stopped on the way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-23 08:40:17 -05:00
parent 891eac1626
commit f0a6a93f8b
4 changed files with 56 additions and 5 deletions
+15 -4
View File
@@ -2848,10 +2848,14 @@ const server = http.createServer(async (req, res) => {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s);
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() };
out.clickSources = await coach.clickSources(out.campaigns.map(c => c.id));
out.hours = await ads.hoursFor(out.campaigns.map(c => c.id)); // on-site views per UTC hour, last 7 days
out.geo = await ads.geoFor(out.campaigns.map(c => c.id)); // on-site serves per viewer country
const all = await ads.listCampaigns(s.email);
// archived ones keep their full stats but leave the working list (Marty, 2026-09-23)
const out = { campaigns: all.filter(c => c.status !== 'archived'), archived: all.filter(c => c.status === 'archived'),
rates: ads.rates(), bannerSizes: ads.bannerSizes() };
const ids = all.map(c => c.id); // archived included: their numbers are the whole point of keeping them
out.clickSources = await coach.clickSources(ids);
out.hours = await ads.hoursFor(ids); // on-site views per UTC hour, last 7 days
out.geo = await ads.geoFor(ids); // on-site serves per viewer country
{ const t = geo.tierLists(siteConfig()); out.tiers = { t1: [...t.t1], t2: [...t.t2] }; out.geoReady = geo.status().loaded; }
const pool = await ads.balances((await myMemberIds(s)).ids, s.email);
out.purchasedCredits = pool.total;
@@ -2905,6 +2909,13 @@ const server = http.createServer(async (req, res) => {
const r = await ads.extendFeatured(s.email, memberId, m[1], b.days);
return json(res, r.error ? 400 : 200, r);
}
m = /^\/api\/my\/campaigns\/(\d+)\/(archive|unarchive)$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = m[2] === 'archive' ? await ads.archive(s.email, m[1]) : await ads.unarchive(s.email, m[1]);
return json(res, r.error ? 400 : 200, r);
}
m = /^\/api\/my\/campaigns\/(\d+)\/(pause|resume)$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);