DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A paused campaign still answers its email, because the pause has a reason column

Nakodo (nakodo.app) does creator outreach automatically: it searches, picks the creators that fit the brief, writes the first email, handles the replies, and introduces the ones who are interested. The Free plan contacts ten new creators a month. Paid plans work in creators a day instead, and you can read the exact numbers on nakodo.app/pricing.

So what should happen at creator number eleven?

This looks like a trivial question and is not, because by the time the limit is reached the user is not in the middle of a request. They are asleep. A cron run is three creators into a batch, four threads are waiting for a follow-up to go out on Tuesday, and one creator replied an hour ago asking what the deliverables are.

Three obvious answers, all wrong:

  • Keep searching, stop sending. The campaign fills up with creators the product cannot write to, which is the one thing the product is for.
  • Hard stop everything. The creator who asked a question an hour ago never gets an answer. We started that conversation on the brand's behalf, so abandoning it is worse than never having written.
  • Stop silently. The user comes back to a campaign that looks active and has done nothing for nine days.

What we wanted is narrower than any of those: stop starting things, finish everything already started.

Status is not enough, so the reason is a column

A campaign has a status, and paused is one of its values. The limit needed a second field:

export async function pauseAtOutreachLimit(userId: string): Promise<number> {
  const paused = await db
    .update(campaigns)
    .set({ status: "paused", pauseReason: "outreach_limit" })
    .where(and(eq(campaigns.userId, userId), eq(campaigns.status, "active")))
    .returning({ id: campaigns.id });
  for (const c of paused) {
    await audit({ action: "campaign.paused", userId, entityType: "campaign", entityId: c.id, metadata: { reason: "outreach_limit" } });
  }
  return paused.length;
}
Enter fullscreen mode Exit fullscreen mode

One statement pauses every running campaign the user owns, because the allowance is per account rather than per campaign. The audit rows carry the reason as metadata too, so the history reads "paused, outreach_limit" rather than just "paused" when somebody asks why nothing happened last Thursday.

Manual pausing, the button in the UI, sets the reason back to null explicitly:

export async function pauseCampaign(campaignId: string, actor: { userId?: string | null } = {}) {
  await db.update(campaigns).set({ status: "paused", pauseReason: null }).where(eq(campaigns.id, campaignId));
  ...
}
Enter fullscreen mode Exit fullscreen mode

Clearing the column is not housekeeping. null means "a human decided this", and three different subsystems are about to branch on that difference.

Branch one: the job queue lets one job type through

Our job queue is one Postgres table, and claiming work joins jobs to their campaign. The eligibility predicate is three alternatives:

or(
  isNull(jobs.campaignId),
  eq(campaigns.status, "active"),
  and(eq(jobs.type, "outreach_draft"), eq(campaigns.pauseReason, "outreach_limit")),
),
Enter fullscreen mode Exit fullscreen mode

The third line is the interesting one. A campaign paused at its outreach limit still gets its outreach_draft jobs claimed, and nothing else. A draft job is "write the first email for a conversation that exists". The conversation row was created while there was still allowance, which means that creator was already counted against the ten. Refusing to write the email now would charge the user for a message that never got sent, which is the kind of off-by-one a customer notices and you cannot argue with.

Searching, enrichment and analysis jobs for the same campaign sit still, because those are the jobs that create more work.

Branch two: the sender finishes its conversations

// A campaign paused at its outreach limit still finishes its conversations.
if (ctx.campaign.status !== "active" && ctx.campaign.pauseReason !== "outreach_limit") {
  await reschedule(thread.id, new Date(Date.now() + 6 * 60 * MINUTE_MS), "Waiting while the campaign is paused.");
  stats.waiting++;
  return;
}
Enter fullscreen mode Exit fullscreen mode

A manually paused campaign parks its threads six hours out, with a human-readable reason stored on the thread so the UI can say why. A campaign paused at the limit sends anyway: follow-ups on their fixed rhythm, answers to a creator's questions, and the introduction when they say yes.

This is the branch that makes the pricing promise true. The page says follow-ups, answers and the introduction are included and that each creator counts once, when we first write to them. That sentence is only honest if the sender ignores the pause for threads that are already open.

Branch three: an upgrade restarts the right pauses

When an account moves to a bigger plan, campaigns get widened: the new plan's extra platform is added, the larger keyword allowance takes effect, and running campaigns relaunch so the change applies now rather than at the next cron tick. The question is which paused campaigns should start again.

if (c.status !== "active" && c.pauseReason !== "outreach_limit") continue;
Enter fullscreen mode Exit fullscreen mode

A campaign the system paused for lack of allowance is exactly the thing the upgrade just fixed, so it resumes. A campaign a human paused stays paused, because the user stopped it for their own reasons and an upgrade is not consent to restart it. That is the whole value of a nullable reason column: the same paused status means two different things, and the code can tell which.

Relaunching is also wrapped in a try/catch that logs and moves on, because the upgrade has already been paid for. A failed relaunch must never fail the plan change.

Refusing to resume, with a date

The other direction matters too. If a user hits resume while the month's allowance is gone, launching would search, find creators, and then be unable to write to any of them:

export async function assertOutreachLeft(userId: string | null): Promise<void> {
  if (!userId) return;
  const allowance = await outreachAllowance(userId);
  if (allowance.monthLeft > 0) return;
  const { plan } = allowance;
  throw new LimitError(
    "creatorsPerMonth",
    `You've reached this month's outreach limit: the ${plan.name} plan contacts ${plan.limits.creatorsPerMonth} new creators a month. ` +
      `Upgrade to keep finding and contacting creators, or resume on ${longDay(allowance.periodEnd)}, when it resets.`,
  );
}
Enter fullscreen mode Exit fullscreen mode

The refusal names the date the allowance comes back, formatted from the subscription's own period end rather than from the first of the month, because that period is whatever Stripe says it is. A limit error that tells you when the limit lifts is a different experience from one that tells you to upgrade.

Counting it correctly in the first place

All of the above assumes the count is right, which takes a lock and a little care:

const { rows, left, monthLeft, per, eligible } = await withUserLock(opts.userId, async (tx) => {
  ...
  const allowance = await outreachAllowance(opts.userId, tx);
  const take = eligible.slice(0, Math.min(allowance.left, opts.max ?? Infinity));
  ...
});
if (monthLeft <= 0) await pauseAtOutreachLimit(opts.userId);
Enter fullscreen mode Exit fullscreen mode

Three things to notice. The allowance is read inside the per-user advisory lock, because two concurrent cron runs for the same account must not both see "two left" and start two conversations each. allowance.left is the smaller of what the month and the day have left, with a per field naming which one ran out, so the UI can say "today" or "this month" accurately. And the pause fires after the insert, driven by the remaining count rather than by an exception, because reaching the limit is not a failure of the thing that reached it.

The eligibility pass just above it deduplicates by hashed address rather than by creator, so a creator our discovery found on both Instagram and TikTok gets one conversation per campaign and is counted once. The address itself is not stored for that comparison, only the hash, which is a decision I wrote about in the post on thread addresses.

One more guard, in the view layer

const atLimit =
  campaign.status === "paused" && campaign.pauseReason === "outreach_limit" && plan.limits.creatorsPerMonth !== null;
Enter fullscreen mode Exit fullscreen mode

The banner that explains the pause checks the plan as well as the reason. A stale reason column on an account that has since moved to a plan with no monthly limit would otherwise show a user an explanation that is no longer true about a plan they no longer have. Reading state that survived a plan change is its own small category of bug.

Go and look

nakodo.app/pricing has the full comparison table, and below it the "What counts as a creator contacted" note, which is the user-facing statement of everything above: each creator counts once when Nakodo writes to them, follow-ups and answers and the introduction are included, creators who never published an email are never contacted or counted, and conversations already started carry on into the next period. Every one of those clauses is a branch in the code.

The table itself is generated from one PlanLimits object, and plan changes happen in the app rather than in Stripe's customer portal, which is its own post. Free needs no card if you want to run an account into its own limit and watch what keeps moving.

Top comments (0)