DEV Community

Daniel Pertu
Daniel Pertu

Posted on

We shipped an undo button and deleted it two minutes later, then brought the same code back for a different state

Nakodo emails creators on a brand's behalf, handles the replies it can, and when a creator says yes it sends one more email introducing the two of them, with the brand's own address in copy. Then it steps out. That introduction is the whole product in one email, and it is described on how it works.

On Wednesday evening, three commits:

19:00  Allow replies after introduction while address is retained
19:02  Remove ability to undo introductions
19:28  Add ability to reopen stopped conversations
Enter fullscreen mode Exit fullscreen mode

A component called ReopenButton and a server action called reopenThreadAction were edited, deleted, then written again from scratch 26 minutes later, for a different state, with different guards. The git history looks like someone thrashing. It is actually the clearest example I have of a rule worth stating out loud.

19:00, the change that made the undo pointless

Until that commit, an introduced conversation was read-only inside Nakodo. The brand had been handed the creator's address by email, so the app's position was: carry on in your own inbox, we are out. Replying in the app returned an error.

That is tidy and slightly unkind, because the brand is already looking at the conversation in Nakodo and now has to go and find it in Gmail. So the panel stays writable while the address is still stored:

const introduced = thread.status === "handed_off";
const canReply = sent && !final && (!introduced || thread.hasAddress);
Enter fullscreen mode Exit fullscreen mode

hasAddress is the interesting field. The brand must never see a creator's email address, which our privacy notice states plainly, along with the fact that the address is removed from the conversation 30 days after the introduction. The UI needs to know whether there is still an address to send through, and nothing more than that, so the shared thread projection ships the predicate instead of the value:

const threadColumns = {
  id: outreachThreads.id,
  hasAddress: sql<boolean>`(${outreachThreads.toEmail} is not null)`,
  ...
};
Enter fullscreen mode Exit fullscreen mode

One is not null in SQL, and the address cannot reach a React prop by accident, because it is never in the object in the first place. This is the argument against reflexively selecting a whole row: every column you did not need is a column some future serialiser can leak.

The other half of that commit is a guard that does not look like much:

export async function answerAsBrand(thread: Thread, ctx: ThreadContext, body: string): Promise<void> {
  await sendInThread(thread, ctx, { kind: "brand", body });
  const now = new Date();
  // After the introduction the brand can still write from here; the
  // conversation stays introduced and Nakodo doesn't follow up.
  if (thread.status === "handed_off") {
    await db.update(outreachThreads).set({ lastSentAt: now }).where(eq(outreachThreads.id, thread.id));
    return;
  }
  // ... the normal path: follow-up counters, next action, status
Enter fullscreen mode Exit fullscreen mode

Without the early return, a brand writing a friendly note in an introduced conversation would restart the automation's follow-up schedule, and Nakodo would start chasing a creator in a conversation it had publicly stepped out of. Letting a user into a state machine late means auditing every write that state machine does on their behalf.

19:02, deleting the undo

With the panel writable, I looked at the button in the corner that said "Undo introduction" and could not find a reason for it. It had one before: undo was the only way back into a conversation you had introduced too early. Now there is no way out of the app to get back into.

But the better reason is that it was never true. The introduction is an email. It is in a human's inbox, it contains the brand's real address, and the creator may have already replied to it directly. The database can set status back to needs_you in a millisecond, and absolutely nothing about the world changes. "Undo" promised the one thing the system cannot do.

So 59 lines went: the component, the action, the import, the confirm dialog with its careful text explaining what undo would and would not do. Writing that dialog copy was what convinced me, actually. If the honest version of a confirm dialog needs four sentences of caveats about what will not be undone, the button is wrong.

19:28, the same code for a state that really is reversible

There is another dead end in the same panel: a conversation the brand stopped. You can press Stop before anything has gone out, or Say no to someone who wrote back. Both close the thread with closedReason: "cancelled", and until that evening a stopped conversation was over for good.

That one genuinely can be undone, and the difference is not in our schema. It is that nothing left the building. A stopped conversation has sent nothing new, told the creator nothing, and handed over no address. Reopening it is a state change with no external shadow.

So ReopenButton came back, with a confirm dialog that needs one sentence, and an action with three guards:

if (thread.status !== "closed") return { ok: true, message: "This conversation is already open." };
if (thread.closedReason !== "cancelled" || !thread.firstSentAt) return fail("This conversation can't be reopened.");
Enter fullscreen mode Exit fullscreen mode

A thread closed because the creator opted out, bounced, reported spam or said a final no is not reopenable, and that is the closedReason check: the reasons are not interchangeable, which is why the column holds a reason and not a boolean. Note also the first line returns success rather than an error. A second click on a button whose state has already changed should not produce a red toast.

The write itself carries its own precondition:

await db
  .update(outreachThreads)
  .set({ status: "needs_you", closedReason: null, closedAt: null, nextActionAt: null, note: "Reopened. Write to them below." })
  .where(and(eq(outreachThreads.id, thread.id), eq(outreachThreads.status, "closed")));
Enter fullscreen mode Exit fullscreen mode

The status check is in the where, not only in the if above it. Between the read and the write, an inbound email or a worker can move the thread on, and a conditional UPDATE is how you make the last writer lose instead of win. Same shape as the idempotent updates elsewhere in this codebase: the guard that decides whether the change is allowed belongs in the statement that makes it.

The two kinds of fact in one row

The reshuffle left a distinction in the schema that I now reach for by default. handedOffAt is a timestamp of something that happened in the world and it is never cleared by anything. status is where the conversation is now and it moves freely. An earlier commit made a conversation introduce only once, and it leans entirely on that: the introduction is gated on handedOffAt, not on the current status, so no sequence of reopening, replying and closing can produce a second introduction email.

The audit log action was reused rather than renamed. It used to mean one thing and now means another:

| "outreach.reopened" // a conversation the brand stopped, opened again
Enter fullscreen mode Exit fullscreen mode

I went back and forth on adding outreach.undone as a separate action. Reusing it is right because an audit action names a user's intention, and in both versions the intention was the same: put this conversation back in front of me. The thing that changed is which conversations are allowed to be put back, and that is a rule, not a vocabulary.

The rule

A thing is reversible when nothing has left the building. Not when your database can still change it, which is almost always, and not when your UI has a spare corner for a button.

Three commits in 28 minutes, 14 net lines, and the product now says "you can undo this" exactly where it is true. The customer-facing promises those states have to keep are on how it works and in the privacy notice, which is also the best single page to read if you want to know why a boolean projection was worth a paragraph.

Top comments (0)