Skip to content
Chapter 58Lesson 4

The pending-invites surface: list, resend, revoke, collide

Build the admin surface for managing pending organization invitations, listing, resending, and revoking them with Server Actions, and translating a Postgres unique-constraint collision into a recoverable conflict.

The last three lessons followed one invite end to end: an admin clicks send, a token is minted, an email goes out, a human accepts. That was one row and one happy path, but it is not where an admin actually spends their time. An admin lives on a screen with several pending invites at once, some opened, some ignored, one a typo, plus a small set of buttons that act on them.

Picture Alice, an admin at Acme. Last week she sent three invites. Bob never opened his. Carol opened hers but hasn’t accepted. Dave’s never arrived, because she typed dav@acme.com instead of dave@acme.com. Today she opens /settings/members and needs four things: to see what’s still pending and how long each invite has left, to resend Bob’s in case it went to spam, to fix Dave by killing the typo and inviting the right address, and to trust that Carol’s hasn’t quietly expired without warning.

That is the whole lesson, and the senior question underneath it is this: what’s the read surface, what are the three actions on a pending row, and what happens when you re-invite an address that already has an invite outstanding? You’ll build one Server Component to render the list, two admin Server Actions to mutate it, and one catch-and-translate that turns a database collision into a recovery the admin can act on.

One idea drives every decision here, so hold onto it: a pending-invite row is a record of what you promised. It says “we offered Dave admin on Tuesday.” Rows are cheap and they’re historical, so you never delete one, you never silently rewrite a promise the email already made, and you never quietly stretch a security window that’s already running. Every action in this lesson, resend, revoke, and re-invite, follows from that one sentence. Learn the sentence first, and the buttons follow.

Reading the pending list: filter expiry in the query, not the row

Section titled “Reading the pending list: filter expiry in the query, not the row”

Build the read first, because the three actions need a surface to mutate, and the surface is a list. This list sits on /settings/members, right next to the member list you built last chapter, which is the table of who’s already in the org. The pending list is its mirror image: who’s been offered a seat but hasn’t taken it. Same page, two tables.

The read itself is a tenant-scoped helper. It lives in db/queries/invitations.ts, the file the accept flow factored last lesson, and it’s called listPendingInvitations(orgId). The naming follows straight from the query conventions: verb-led and list-prefixed, returning an array, so the name tells you it’s plural and read-only before you’ve read a line of the body.

Here is the shape of the query. Exactly one beat in it matters.

export async function listPendingInvitations(orgId: string) {
const db = tenantDb(orgId);
return db.query.invitation.findMany({
where: and(
eq(invitation.status, 'pending'),
gt(invitation.expiresAt, new Date()),
),
with: { inviter: true },
orderBy: desc(invitation.createdAt),
});
}

The predicate, and the one line worth pausing on. “Pending” is two conditions, not one: the status column says 'pending' and the row hasn’t expired yet. An invite whose expiresAt slid into the past is operationally no longer pending, even though the status string still reads 'pending'. Expiry is computed from the clock, never stored as a status, the rule from the first lesson restated here at the read site.

export async function listPendingInvitations(orgId: string) {
const db = tenantDb(orgId);
return db.query.invitation.findMany({
where: and(
eq(invitation.status, 'pending'),
gt(invitation.expiresAt, new Date()),
),
with: { inviter: true },
orderBy: desc(invitation.createdAt),
});
}

The join. with: { inviter: true } pulls each invite’s inviter, the admin who sent it, in the same round-trip, so you can render “invited by Alice” without a second query per row. That’s one query for the whole list instead of one per row.

export async function listPendingInvitations(orgId: string) {
const db = tenantDb(orgId);
return db.query.invitation.findMany({
where: and(
eq(invitation.status, 'pending'),
gt(invitation.expiresAt, new Date()),
),
with: { inviter: true },
orderBy: desc(invitation.createdAt),
});
}

Newest first. desc(createdAt) puts the most recent invite at the top, which is the order an admin scans in.

1 / 1

The load-bearing decision is in step 1: you filter expiresAt > now() inside the where clause, not after the rows come back. The tempting alternative is to select every 'pending' row and then drop the expired ones in JavaScript with a .filter(). Avoid that. It pulls rows across the wire only to throw them away, and in a tenant system every row you load is a row you’re now responsible for scoping correctly. The database is built to filter on a timestamp, so let it. This is the same discipline the first lesson set when it ruled out a cron job to mark invites expired: there is no background process flipping a status. The where clause is the only thing that decides “still pending,” and it decides it fresh on every read.

Each row renders the invited email, the role, the inviter’s name (inviter.name, riding along from that join), the date it was sent, a countdown such as “expires in 3 days,” and a per-row action menu with two buttons, Resend and Revoke. The countdown is purely a display concern: it’s derived from expiresAt and formatted with the project’s existing date helpers, so there’s nothing clever to do there.

One authorization note, because it’s easy to skip: this list is admin-only. A plain member opening the org’s settings must not see who’s been invited, since that’s privileged information. The surrounding page is already gated behind roleAtLeast('admin') from the last chapter, and every mutation below runs through authedAction('admin', …), so the write side enforces it too. The read inherits the page’s guard, and the writes carry their own.

Resending an invite rotates the token and the window

Section titled “Resending an invite rotates the token and the window”

This is the first action, and it’s the only one in the lesson with a real decision to make.

The action is resendInvitation. The name follows the convention the chapter locked in: sendInvitation, acceptInvitation, resendInvitation, all verb plus noun with no Action suffix. It takes one input, the id of the invite to resend:

const resendInvitationSchema = z.object({ invitationId: z.uuid() });
export const resendInvitation = authedAction(
'admin',
resendInvitationSchema,
async ({ invitationId }, ctx) => {
// ...
},
);

Now the decision. Alice clicks “resend” on Bob’s invite. What, exactly, does she send? There are two honest answers, and only one of them is what an experienced developer reaches for.

await tx
.update(invitation)
.set({ /* no new token */ })
.set({ /* same expiresAt — window keeps running */ })
.where(eq(invitation.id, invitationId));
// reuse the original acceptUrl, re-send the same email

Cheap, and quietly wrong. It re-fires the original email with the original link, so the original window keeps running: a resend the day before expiry buys Bob about 24 hours, not a fresh week. And if Bob forwarded that first email to a colleague, the link in it still works, so you’ve re-confirmed a credential you have no control over.

Rotation is the right choice, because a resend is a security event, not just a UX one. The first lesson framed the token as a bearer credential and the expiry as a security primitive: anyone holding the token can accept, and the window exists precisely so an old link can’t be cashed in months later. A resend that preserves the old token and the old window honors neither of those, while rotating refreshes both. Replacing a still-valid secret with a fresh one so the old copy stops working is called token rotation , and it’s the same move you’ll make for API keys, password resets, and session tokens. Learn it here.

Mechanically, resendInvitation is a diff against sendInvitation, whose eight-step shape you already know cold. Resend reuses six of those steps untouched: token generation, the tokenHash write, signedInviteUrl, the audit write inside withTenant, the email send after the commit, and the revalidatePath. Three things change: it’s an UPDATE keyed by invitationId instead of an INSERT, the audit action is 'invitation.resent', and there’s a precondition guard up front.

That guard matters. Before it rotates anything, the action has to confirm the row is actually a live pending invite. You cannot resend an invite Bob already accepted, because there’s nothing to resend once he’s a member, and you cannot resend one that was revoked. So the action reads the row first, filtered on status = 'pending', and a miss returns err('not_found', …) rather than silently re-sending. It’s the same discipline the accept flow used: every write that depends on the invite still being pending checks that it still is.

Here is the full body, with the rotation folded in.

const rawBytes = crypto.getRandomValues(new Uint8Array(32));
const rawToken = Buffer.from(rawBytes).toString('base64url');
const newExpiresAt = new Date(Date.now() + INVITATION_TTL_SECONDS * 1000);
const row = await withTenant(ctx.orgId, async (tx) => {
const current = await tx.query.invitation.findFirst({
where: and(
eq(invitation.id, invitationId),
eq(invitation.status, 'pending'),
),
columns: { email: true, role: true, expiresAt: true },
});
if (!current) return null;
await tx
.update(invitation)
.set({ tokenHash: await sha256(rawToken), expiresAt: newExpiresAt })
.where(eq(invitation.id, invitationId));
await logAudit(tx, {
action: 'invitation.resent',
subjectType: 'invitation',
subjectId: invitationId,
payload: {
email: current.email,
role: current.role,
oldExpiresAt: current.expiresAt,
newExpiresAt,
},
});
return current;
});
if (!row) return err('not_found', 'This invite is no longer pending.');
const orgName = await getOrgName(ctx.orgId);
const acceptUrl = await signedInviteUrl(invitationId, rawToken);
const sent = await sendEmail({
to: row.email,
subject: `You're invited to ${orgName}`,
react: (
<InviteEmail
orgName={orgName}
inviterName={ctx.user.name}
acceptUrl={acceptUrl}
expiresAt={newExpiresAt}
/>
),
idempotencyKey: `invite-resend:${invitationId}:${newExpiresAt.getTime()}`,
});
revalidatePath('/settings/members');
return ok({ emailSent: sent.ok });

Mint the new token and the new window first, in memory, before touching the database. That’s a fresh 32-byte token, the two lines you know from the send lesson, and expiresAt pushed a full TTL out from now. Nothing is committed yet.

const rawBytes = crypto.getRandomValues(new Uint8Array(32));
const rawToken = Buffer.from(rawBytes).toString('base64url');
const newExpiresAt = new Date(Date.now() + INVITATION_TTL_SECONDS * 1000);
const row = await withTenant(ctx.orgId, async (tx) => {
const current = await tx.query.invitation.findFirst({
where: and(
eq(invitation.id, invitationId),
eq(invitation.status, 'pending'),
),
columns: { email: true, role: true, expiresAt: true },
});
if (!current) return null;
await tx
.update(invitation)
.set({ tokenHash: await sha256(rawToken), expiresAt: newExpiresAt })
.where(eq(invitation.id, invitationId));
await logAudit(tx, {
action: 'invitation.resent',
subjectType: 'invitation',
subjectId: invitationId,
payload: {
email: current.email,
role: current.role,
oldExpiresAt: current.expiresAt,
newExpiresAt,
},
});
return current;
});
if (!row) return err('not_found', 'This invite is no longer pending.');
const orgName = await getOrgName(ctx.orgId);
const acceptUrl = await signedInviteUrl(invitationId, rawToken);
const sent = await sendEmail({
to: row.email,
subject: `You're invited to ${orgName}`,
react: (
<InviteEmail
orgName={orgName}
inviterName={ctx.user.name}
acceptUrl={acceptUrl}
expiresAt={newExpiresAt}
/>
),
idempotencyKey: `invite-resend:${invitationId}:${newExpiresAt.getTime()}`,
});
revalidatePath('/settings/members');
return ok({ emailSent: sent.ok });

Read the row first, inside the transaction, and that read is the guard. The where only matches a still-pending invite, so a null means there’s nothing live to resend. Grabbing expiresAt here, before the update, is what lets the audit record the genuine old window; an UPDATE … RETURNING would hand back the new value, not the old one.

const rawBytes = crypto.getRandomValues(new Uint8Array(32));
const rawToken = Buffer.from(rawBytes).toString('base64url');
const newExpiresAt = new Date(Date.now() + INVITATION_TTL_SECONDS * 1000);
const row = await withTenant(ctx.orgId, async (tx) => {
const current = await tx.query.invitation.findFirst({
where: and(
eq(invitation.id, invitationId),
eq(invitation.status, 'pending'),
),
columns: { email: true, role: true, expiresAt: true },
});
if (!current) return null;
await tx
.update(invitation)
.set({ tokenHash: await sha256(rawToken), expiresAt: newExpiresAt })
.where(eq(invitation.id, invitationId));
await logAudit(tx, {
action: 'invitation.resent',
subjectType: 'invitation',
subjectId: invitationId,
payload: {
email: current.email,
role: current.role,
oldExpiresAt: current.expiresAt,
newExpiresAt,
},
});
return current;
});
if (!row) return err('not_found', 'This invite is no longer pending.');
const orgName = await getOrgName(ctx.orgId);
const acceptUrl = await signedInviteUrl(invitationId, rawToken);
const sent = await sendEmail({
to: row.email,
subject: `You're invited to ${orgName}`,
react: (
<InviteEmail
orgName={orgName}
inviterName={ctx.user.name}
acceptUrl={acceptUrl}
expiresAt={newExpiresAt}
/>
),
idempotencyKey: `invite-resend:${invitationId}:${newExpiresAt.getTime()}`,
});
revalidatePath('/settings/members');
return ok({ emailSent: sent.ok });

The rotation itself: one UPDATE keyed by the primary key. .set(...) overwrites tokenHash and pushes expiresAt to the new window. Reading then updating the same row by its id inside one transaction is safe, because no other writer can slip between them the way two inserts could race on the email uniqueness.

const rawBytes = crypto.getRandomValues(new Uint8Array(32));
const rawToken = Buffer.from(rawBytes).toString('base64url');
const newExpiresAt = new Date(Date.now() + INVITATION_TTL_SECONDS * 1000);
const row = await withTenant(ctx.orgId, async (tx) => {
const current = await tx.query.invitation.findFirst({
where: and(
eq(invitation.id, invitationId),
eq(invitation.status, 'pending'),
),
columns: { email: true, role: true, expiresAt: true },
});
if (!current) return null;
await tx
.update(invitation)
.set({ tokenHash: await sha256(rawToken), expiresAt: newExpiresAt })
.where(eq(invitation.id, invitationId));
await logAudit(tx, {
action: 'invitation.resent',
subjectType: 'invitation',
subjectId: invitationId,
payload: {
email: current.email,
role: current.role,
oldExpiresAt: current.expiresAt,
newExpiresAt,
},
});
return current;
});
if (!row) return err('not_found', 'This invite is no longer pending.');
const orgName = await getOrgName(ctx.orgId);
const acceptUrl = await signedInviteUrl(invitationId, rawToken);
const sent = await sendEmail({
to: row.email,
subject: `You're invited to ${orgName}`,
react: (
<InviteEmail
orgName={orgName}
inviterName={ctx.user.name}
acceptUrl={acceptUrl}
expiresAt={newExpiresAt}
/>
),
idempotencyKey: `invite-resend:${invitationId}:${newExpiresAt.getTime()}`,
});
revalidatePath('/settings/members');
return ok({ emailSent: sent.ok });

The audit write, in the same transaction. 'invitation.resent' carries both oldExpiresAt (captured in the read above) and newExpiresAt, so the rotation is now an auditable fact: this invite’s window moved from here to there, at this time. Row and audit share one commit.

const rawBytes = crypto.getRandomValues(new Uint8Array(32));
const rawToken = Buffer.from(rawBytes).toString('base64url');
const newExpiresAt = new Date(Date.now() + INVITATION_TTL_SECONDS * 1000);
const row = await withTenant(ctx.orgId, async (tx) => {
const current = await tx.query.invitation.findFirst({
where: and(
eq(invitation.id, invitationId),
eq(invitation.status, 'pending'),
),
columns: { email: true, role: true, expiresAt: true },
});
if (!current) return null;
await tx
.update(invitation)
.set({ tokenHash: await sha256(rawToken), expiresAt: newExpiresAt })
.where(eq(invitation.id, invitationId));
await logAudit(tx, {
action: 'invitation.resent',
subjectType: 'invitation',
subjectId: invitationId,
payload: {
email: current.email,
role: current.role,
oldExpiresAt: current.expiresAt,
newExpiresAt,
},
});
return current;
});
if (!row) return err('not_found', 'This invite is no longer pending.');
const orgName = await getOrgName(ctx.orgId);
const acceptUrl = await signedInviteUrl(invitationId, rawToken);
const sent = await sendEmail({
to: row.email,
subject: `You're invited to ${orgName}`,
react: (
<InviteEmail
orgName={orgName}
inviterName={ctx.user.name}
acceptUrl={acceptUrl}
expiresAt={newExpiresAt}
/>
),
idempotencyKey: `invite-resend:${invitationId}:${newExpiresAt.getTime()}`,
});
revalidatePath('/settings/members');
return ok({ emailSent: sent.ok });

After COMMIT, refuse or send. If the transaction returned null, the row wasn’t pending, so return err('not_found', …) and send no email. Otherwise rebuild the signed URL with the new token, send the new email, revalidate, and return. COMMIT is the pivot: the new hash is durable before the email carrying the new token goes out.

1 / 1

That last point is the atomicity rule the send lesson stressed. The new tokenHash commits first, and the email carrying the matching raw token sends after. Flip the order and the link 404s, because the email would arrive with a token whose hash isn’t in the database yet. Skip the commit and you’d have the new email circulating against the old hash. COMMIT sits between the write and the send for exactly this reason.

Revoking an invite cancels the row, it never deletes it

Section titled “Revoking an invite cancels the row, it never deletes it”

Revoke is the cheapest action in the lesson, and it’s the one that teaches the tombstone idea most directly. Alice killing Dave’s typo’d invite is the whole motivating case.

The action is revokeInvitation, with the same wrapper and the same one-field schema:

const revokeInvitationSchema = z.object({ invitationId: z.uuid() });
export const revokeInvitation = authedAction(
'admin',
revokeInvitationSchema,
async ({ invitationId }, ctx) => {
// ...
},
);

The body is short enough to read in one glance: one guarded UPDATE, one audit row, one revalidate.

const row = await withTenant(ctx.orgId, async (tx) => {
const [updated] = await tx
.update(invitation)
.set({ status: 'canceled' })
.where(
and(eq(invitation.id, invitationId), eq(invitation.status, 'pending')),
)
.returning({ email: invitation.email, role: invitation.role });
if (!updated) return null;
await logAudit(tx, {
action: 'invitation.revoked',
subjectType: 'invitation',
subjectId: invitationId,
payload: { email: updated.email, role: updated.role },
});
return updated;
});
if (!row) return err('not_found', 'This invite is no longer pending.');
revalidatePath('/settings/members');
return ok({ revoked: true });

Two decisions are baked into those lines, and both come straight from the tombstone idea.

The first: the status flips to 'canceled' and the row is never deleted. That row is the historical record: “Alice offered Dave admin on this date, and it was revoked.” Deleting it would cost you two things. You’d lose the audit trail, the answer to “did we ever invite this address?” And you’d break the accept side, because the previous lesson wired the accept page’s canceled branch to render an honest “this invite was revoked” message. If Dave’s typo’d link somehow reaches a real inbox and someone clicks it after the revoke, the row being there is what lets the page say “revoked” instead of a generic “something’s wrong.” Revoke closes a loop the accept flow already expects. Keeping the row is nearly free, and the record it holds is irreplaceable: the same cost asymmetry the first lesson used to justify keeping accepted rows forever.

The second decision is something the action deliberately doesn’t do: no “your invite was canceled” email goes to the invitee. This is worth pausing on, because not sending is as much a design choice as sending.

The where … and status = 'pending' guard does the same job it did on resend. Revoking an invite Bob already accepted has to refuse, because you can’t un-invite a member by canceling their old invitation. That’s a different flow entirely: removing a member is member management, which lives next to this on the same settings page and runs through its own action. Revoking an already-canceled or already-expired row is a harmless no-op, since the guard matches nothing, the action returns not_found, and nothing changes.

You might wonder why you’re hand-rolling this at all. Better Auth’s organization plugin ships auth.api.cancelInvitation({ invitationId }), which flips the status to 'canceled' for you and fires its own before/after hooks. It works, but it doesn’t know about your logAudit shape, and it writes outside your withTenant transaction, so the audit row wouldn’t ride the same commit as the status flip. The send and accept paths were hand-rolled for exactly this reason, and revoke stays consistent: a direct UPDATE … + logAudit(tx) inside withTenant, so the cancellation and its audit record share one fate. The plugin method exists, but you’re choosing the shape that keeps your audit trail honest.

Re-inviting a pending address: let the index throw, then translate

Section titled “Re-inviting a pending address: let the index throw, then translate”

Now the “collide” in the title. Alice goes to fix Dave, but in her haste she re-types bob@acme.com, and Bob already has a pending invite. The send action runs again. What happens?

First, be precise about which collision this is. “Bob already has a pending invite” is a second pending invitation hitting the same address. That is a different situation from “Bob is already a member of Acme” because he accepted weeks ago, which needs a membership check before the invite even tries to write. That second case is the next lesson’s. This section owns only the pending-on-pending collision, and it’s purely a database-constraint story.

The naive instinct is to guard it with a check: SELECT to see if a pending invite exists, and only INSERT if it doesn’t. Reach for that and you’ve written a race condition.

const existing = await tx.query.invitation.findFirst({
where: and(
eq(invitation.organizationId, orgId),
eq(invitation.status, 'pending'),
sql`lower(${invitation.email}) = ${email}`,
),
});
// ── a second click can land right here, before the insert ──
if (existing) return err('conflict', 'Already invited.');
await tx.insert(invitation).values({ /* ... */ });

There’s a race in the gap. Two fast clicks, or two admins acting at once, both run the SELECT, both see nothing, and both fall through to the INSERT. You get two pending rows for one address. The window between checking and inserting is the bug, and no amount of careful reading closes it, because the two statements aren’t atomic.

What makes the right column work is the partial unique index you built in the first lesson: (organization_id, lower(email)) WHERE status = 'pending', named invitation_org_email_pending_unique. It encodes the business rule, at most one pending invite per address per org, as a database constraint. So the pattern is not “check, then write.” The pattern is don’t pre-check; let the write fail, and catch-and-translate. The send action deliberately left this collision uncaught precisely so you’d handle it here, where the recovery UI lives.

You’ve done the generic half of this catch before. Since the create-invoice action back in chapter 047, the project has shipped isUniqueViolation(e) in lib/result.ts, the helper that answers “is this a Postgres unique violation (23505) ?” so a duplicate maps to a conflict instead of a 500. It already handles the detail that bites people: in current Drizzle the Postgres error doesn’t surface flat. Drizzle wraps it, so the thrown value is a DrizzleQueryError, and the real Postgres error sits on its .cause, a DatabaseError carrying the .code. That’s why isUniqueViolation reads e.cause.code === '23505' rather than a top-level error.code, which would be undefined. Consume the primitive you already built instead of re-deriving the generic check.

What this collision needs on top of that generic check is one extra narrowing. A bare 23505 could in principle come from any unique index on the table, and isUniqueViolation can’t tell which constraint fired, only that one did. Translating every unique violation into a friendly “already invited” would mistranslate an unrelated collision into the wrong message. So you key this branch to the constraint name: error.cause.constraint === 'invitation_org_email_pending_unique', the partial index from the first lesson. Match that, and you’ve confirmed it’s the pending-email rule that tripped; anything else rethrows as a real error.

That refinement is why the catch reads the .cause directly rather than delegating to isUniqueViolation: it needs .constraint, which the boolean helper doesn’t expose. You still avoid the loose catch (e: any), because the caught unknown goes through the project’s ensureError helper first. ensureError doesn’t surface the cause, since a DrizzleQueryError is already an Error, so the helper returns it untouched, and Drizzle is what set .cause in the first place. What it buys you is a typed Error value to read .cause and .constraint off of, instead of reaching into a loose any.

Now close the loop on the UX, because a raw “conflict” is a dead end and you can do better. When the action returns the conflict, the screen already has everything it needs to recover: Bob’s pending row is right there in the list, with its Resend and Revoke buttons. So the conflict surfaces as an actionable prompt, “Bob already has a pending invite. Resend it, or revoke and start over?”, wiring the two buttons to the very actions you just built. The constraint’s signal becomes a two-button recovery. The whole lesson folds together at this point: the read renders Bob’s row, the collision detects the duplicate, and resend and revoke are the way out.

That pattern, the database is the source of truth for the collision and the action’s job is translation, is the transferable senior idea here. It isn’t about invitations. It’s the shape you reach for any time a unique constraint guards a rule and a naive pre-check would race. Now write the translation yourself.

Implement tryCreateInvite. Call the provided insertInvite(email) — it resolves with { id } on a fresh address and rejects on a duplicate. Return { ok: true, invitationId } on success. On a unique violation (the caught error's code === '23505'), return { ok: false, error: { code: 'conflict', existingInvitationId } }. Any other error must rethrow — narrow on the code, don't treat every throw as a conflict. The provided isDuplicateError guard narrows the caught unknown for you. Note: to keep this sandbox self-contained, the fake insertInvite throws a flat error object; in production Postgres the same error arrives wrapped in a DrizzleQueryError at .cause, which you narrow exactly the same way (the shape shown in the variants above).

    Reveal solution
    export async function tryCreateInvite(email: string): Promise<InviteResult> {
    try {
    const { id } = await insertInvite(email);
    return { ok: true, invitationId: id };
    } catch (error) {
    // Narrow on the code, not on "something threw". A 23505 is the
    // pending-email unique index firing — translate it to a typed
    // conflict the UI turns into "resend or revoke?". Anything else
    // (a 23503, a dropped connection) is a real failure: rethrow it.
    if (isDuplicateError(error) && error.code === '23505') {
    return {
    ok: false,
    error: { code: 'conflict', existingInvitationId: error.existingInvitationId },
    };
    }
    throw error;
    }
    }

    In production the Postgres error is wrapped, so you’d narrow on error.cause instanceof DatabaseError && error.cause.code === '23505' (and key on error.cause.constraint === 'invitation_org_email_pending_unique') instead of the flat error.code. The branch logic is identical; only the path to the code changes.

    The recently-expired shelf: a fresh send, not a magic re-extension

    Section titled “The recently-expired shelf: a fresh send, not a magic re-extension”

    Back to Carol. Her invite expired last Tuesday: status is still 'pending', but expiresAt slid into the past, so your read filtered it out of the main list entirely. That’s correct, because it’s not actionable as a pending invite anymore. But Alice still wants to see that Carol’s expired and do something about it, so dropping it on the floor isn’t an option.

    The answer is a second, smaller list: a collapsed “Recently expired” section under the main one, read by a sibling helper, listExpiredInvitations(orgId). It’s the same query as the live list with the inequality flipped and a floor added so it doesn’t trawl the entire history.

    where: and(
    eq(invitation.status, 'pending'),
    lt(invitation.expiresAt, now),
    gt(invitation.expiresAt, thirtyDaysAgo),
    ),

    Read those three lines against the live list’s predicate and the difference is exact: status is still 'pending', but expiresAt is now less than now, so it’s in the past, bounded below by a 30-day floor so the shelf shows recent expirations and not invites that died last year. That’s one inequality direction plus a floor: the same column, on the opposite side of the clock.

    The structural point is in the button on each expired row. It reads “Send new invite,” and it routes to sendInvitation, a brand-new row with a brand-new token, not to resendInvitation. An expired invite cannot be re-extended, because there is nothing live to rotate and the old token is dead. The first lesson said exactly this: resending an expired invite mints a new row, it never stretches the old one. Here that rule becomes a concrete control the admin clicks.

    That’s also why the two lists are visually separate. If expired invites showed up in the live list still wearing a “Resend” button, the UI would be implying the dead token could be revived. The separate shelf with its different verb encodes the truth: this is a fresh start, not a revival. Resend rotates a living invite, while expiry doesn’t get rotated, it gets replaced.

    What changing your mind looks like: revoke-and-reinvite, not silent edits

    Section titled “What changing your mind looks like: revoke-and-reinvite, not silent edits”

    Two more decisions before the summary, and both are about an action you should not build. They’re pure reasoning, and they both fall out of the tombstone idea one more time.

    First, changing the role on a pending invite. Alice invited Bob as member, then realized she meant admin. The instinct is an “edit role” control on the pending row that flips invitation.role from member to admin in place. Don’t build it. The reason is the promise: Bob’s email already said “you’ve been invited as a member.” Quietly rewriting the row to admin makes the email say one thing and the accept screen say another, so the row would no longer match the promise that left the building. The honest fix is two clicks: revoke the member invite, then send a fresh admin one. That’s one coherent promise per invite. Some products do allow the in-place edit, but for a first-year SaaS the simpler and more honest shape wins.

    Second, a related boundary worth naming. Once Bob accepts, his role lives on the member row, and changes to it go through changeMemberRole from the last chapter, not through the invitation. At that point invitation.role is frozen history: it answers “what role was Bob invited as?”, and the audit log reads from it. Never try to keep invitation.role and member.role in sync after acceptance. They describe two different moments, the offer and the standing membership, and conflating them corrupts both records.

    That brings the whole lesson back to its one sentence. Resend, revoke, re-invite, and changing your mind by re-inviting all preserve the row’s record of what was promised and when. Resend rotates the token but keeps the row. Revoke flips the status but keeps the row. A blocked re-invite writes no row at all. The only thing in this entire system that ever deletes a row is a retention job on a long horizon, sweeping up terminal invites months later, and that’s a much later unit’s concern. Until then, rows are tombstones: you don’t dig them up, you lay new ones beside them.

    Here are the three actions side by side, so the differences sit in one glance: resend sends and rotates, revoke does neither, and a collision writes nothing.

    Action
    DB write
    Sends email?
    Audit event
    Row after
    Resend

    UPDATE tokenHash, expiresAt

    Yes new link
    invitation.resent

    still pending, new window

    Revoke

    UPDATE status = 'canceled'

    No
    invitation.revoked

    canceled (kept)

    Re-invite (collision)

    INSERT blocked by index

    No returns conflict
    no row written

    existing pending unchanged

    Three actions on a pending row. Only resend sends an email, only resend rotates the token, and the blocked re-invite touches nothing. Every path preserves the row's record.

    That grid is the lesson in one frame: three buttons, three behaviors, and one discipline holding them together. The row records what you promised, so you rotate it, you cancel it, or you refuse to duplicate it, but you never quietly erase what it remembers.

    Four places to go deeper: the plugin you chose not to lean on, the two Postgres mechanics that carry this lesson, and the security principle behind token rotation.