Skip to main content

October 26, 2026

Withdraw must reopen mirrored invite status

A creator withdrew, the invitation stayed applied, and re-invites failed. If a row copies another row's state, the undo path has to move it back.

Sander Korf4 min read
postgresnestjstypeorm

Businesses post paid campaigns on a two-sided marketplace mobile app, creators apply to them, and a business can also invite creators it likes to apply. The business side keeps an invited list and an invite cap per campaign: only so many creators may sit in pending or applied invite slots at once. The creator side shows an invite banner when an invitation is open, then an application row after they tap apply.

A business could invite a creator, watch them apply, then withdraw and discover it could not invite them again. The creator who withdrew still looked applied on the business invited list. After withdraw, the invitation row still said applied, the cap still counted that creator, and a second invite was silently skipped as already applied, so the invite banner never came back on the creator side.

Mirrored status and the missing undo path

Mirrored status means one table stores a copy of a fact that really lives elsewhere. Here the application row owns whether someone applied; the invitation row keeps a copied status for fast lists and cap math. A forward transition is apply: insert the application and flip the invitation from pending to applied. An undo transition is withdraw: remove the application and should flip applied back to pending.

The trap: apply updated the copy in the apply module. Withdraw lived in older code, written before invitations existed. It hard-deletes the application row, so there is no status transition to hook. Happy-path tests invite, apply, and stop. Nothing throws. The tables just disagree.

What the business and creator see

On the business campaign screen, invited creators show a status chip and feed the cap counter. On the creator home feed, a pending invite drives a banner and a one-tap apply action. After withdraw, the chip stayed applied while the application row was gone. Re-invite logic checked the stale chip, not the missing application, and returned without error. We caught it in development before release, so no end user hit the broken flow.

Invite the creator, apply, then withdraw, and watch the invite cap in each mode. In Withdraw deletes only, the chip stays applied, Invite creator answers with skipped already applied, and the cap does not free a slot. Switch to Withdraw also reopens invite, run the same clicks, and the chip returns to pending, the banner comes back, and the creator can apply again.

Simulation

Withdraw vs mirrored invite status

Toy campaign K, one invited creator, and mirrored invitation status. Cap is three active invites.

Campaign · Summer product shoot

Active invites

1 / 3

Pending and applied invites both count

Invitation list

  • Jordan LeePending

Creator view

Jordan Lee

Summer product shoot

You are invited to apply

No application on file

Invite the creator, have them apply, then withdraw. Compare both withdraw modes.

Withdraw path

One transaction per direction

The fix pairs each direction with a single PostgreSQL transaction in NestJS with TypeORM.

Apply: insert the application, then run a conditional update on the invitation (WHERE status = 'pending') so a cancelled or declined invite is left alone.

Withdraw: delete the application, then conditional update applied back to pending with respondedAt cleared. Running withdraw twice stays a no-op because the second update matches zero rows.

When counting active invites for the cap, lock the parent campaign row with SELECT ... FOR UPDATE so two concurrent invites cannot both slip under the limit.

Before:

async withdraw(userId: number, campaignId: number) {
  const app = await applications.findPending(userId, campaignId)
  await applications.remove(app) // invitation still says 'applied'
}

After:

async withdraw(userId: number, campaignId: number) {
  const app = await applications.findPending(userId, campaignId)
  await db.transaction(async (tx) => {
    await tx.applications.remove(app)
    await tx.invitations.update(
      { campaignId, creatorId: userId, status: 'applied' },
      { status: 'pending', respondedAt: null },
    )
  })
}

I added a Jest integration test against real Postgres that invites, applies, withdraws, and asserts the business can invite again with the cap correct.

Reusable rule and when to skip the copy

If one record copies another record's lifecycle, every path that changes or deletes the source must move the copy back in the same transaction. Better still, do not store the copy.

When applied can be computed at read time with a join or EXISTS subquery, compute it. A derived value cannot drift. Store mirrored status only when you need it for indexing, caps, or history. If the copy is audit ("this creator once applied from an invite"), use a separate history field, not the live status. If the two records live in different services or databases, one transaction is not available: use an outbox event and a reconcile job.

Mirrored fields are cheap on the happy path and expensive on every undo you forget to wire.

Happy coding!

Sander