DEV Community

Daniel Pertu
Daniel Pertu

Posted on

An internal tool that edits entitlements has to write two records, not one

Every product eventually needs the screen where support fixes an account: the refund that did not revoke, the webhook that never landed, the goodwill unlock after a long email thread. Ours can change a plan tier, tick provider unlocks on and off, set a token count, flip an access flag and adjust an interview credit balance.

The interesting engineering in that screen is not the form. It is the rule that no edit may be the only record of itself.

Rule one: the balance is never written blind

The obvious implementation of "set credits to 5" is an UPDATE on the balance. We do not do that, because the balance is not the truth; it is a cached total of a ledger the product already maintains. A support adjustment goes through that ledger like everything else:

const currentBalance = row?.balance ?? 0;
const delta = target - currentBalance;
if (delta === 0) return null;

await tx.insert(interviewCreditTransactionsTable).values({
  id: crypto.randomUUID(),
  user_id: userId,
  type: delta > 0 ? 'grant' : 'spend',
  amount: delta,
  pack_id: `admin-adjustment:${crypto.randomUUID()}`,
  created_at: new Date(),
});
Enter fullscreen mode Exit fullscreen mode

The operator types a target; the code derives the delta and writes the transaction that justifies it. The balance still reconciles against its own history afterwards, which is the property that makes the ledger worth having at all. And because the ledger is part of what a customer can download about themselves, the adjustment is visible to them, labelled admin-adjustment, rather than being an unexplained jump in a number.

That labelling decision is worth being deliberate about. pack_id normally carries which credit pack was bought. Reusing it as a provenance field for support actions means every row in the ledger can answer "where did this come from", with no nullable column and no second table.

Rule two: every change writes a before and after

The mutation path stages each column through one helper:

function track<T>(
  column: string,
  from: T,
  to: T | undefined,
  equal: (a: T, b: T) => boolean = Object.is
): void {
  if (to === undefined || equal(from, to)) return;
  changes.push({ field: column, from, to });
  userUpdate[column] = to;
}
Enter fullscreen mode Exit fullscreen mode

Three things fall out of this shape.

An unchanged field produces neither a write nor an audit row. Without the equality check, saving a form that the operator only looked at would file a change against someone's account, and an audit log full of no-ops is an audit log nobody reads.

The audit field string and the database column name are deliberately identical. When someone six months from now reads a change record, it names the column they would go and inspect, not a friendly label that has to be mapped back.

The comparison function is a parameter because one of the fields is an array of provider slugs. Both sides sort their copy before comparing, and the same helper is exported and used by the form's dirty check, so the client and the server always agree on whether anything changed. If they disagreed, a save would either no-op silently or file a spurious change.

Rule three: the audit trail cannot outlive a rollback

The audit row is written after the transaction commits, not inside it, and the list of changes is returned out of the transaction rather than assigned to a variable in the enclosing scope:

const changes = await db.transaction(async (tx): Promise<FieldChange[] | null> => {
  const [current] = await tx
    .select()
    .from(usersTable)
    .where(eq(usersTable.id, userId))
    .limit(1)
    .for('update');
  // ...
});
Enter fullscreen mode Exit fullscreen mode

A rolled-back transaction must not leave behind a list of changes for the audit log to report as though they happened. Scoping the list inside the callback makes that structurally impossible rather than a thing to remember.

The for('update') is there because the credit path reads a balance and writes a delta against it. Two operators saving the same account at the same moment without that lock lose one of the two edits, and "lost" here means a customer's balance is wrong and both audit rows claim to be correct.

The audit row is also filed against the account that was changed, not against the operator who changed it. The operator's identity goes in the details. This is the right way round because the record belongs to the person whose entitlements moved.

Two smaller decisions

The numeric fields are capped at 100:

export const ADMIN_ACCOUNT_LIMITS = {
  providerTokensMax: 100,
  interviewCreditsMax: 100,
  searchResultsMax: 25,
  searchQueryMax: 320,
} as const;
Enter fullscreen mode Exit fullscreen mode

These are support limits, not product limits. No real customer is anywhere near them, so a value above one is an extra zero rather than an intent. Enforced on the server as well as in the form, because the form is not a security boundary.

And the shared type file for the screen has no server-only import in it: no database handle, no schema, no game catalogue. The client screen imports the DTOs; the list of providers the picker renders is passed down as props from the server page. Otherwise an internal tool that maybe five people ever open would pull the entire content catalogue into a browser bundle.

Search escapes LIKE wildcards too, so a pasted email with an underscore in it is a string and not a pattern:

function escapeLike(value: string): string {
  return value.replace(/[\\%_]/g, (char) => `\\${char}`);
}
Enter fullscreen mode Exit fullscreen mode

See it for yourself. Read the "Information We Collect" section of cogniprep.app/privacy. It enumerates the exact fields this screen can edit ("plan tier and the products you have unlocked: provider access, Premium, Assessment Centre access, interview credits") and, separately, "interview credit transactions: a ledger of credit grants, purchases, usage, and refunds". Those two lines are the customer-facing statement of the two rules above: what an operator can change, and the ledger that explains any change to a balance.

The test I would apply to any internal tool: pick the most sensitive field on the screen, imagine the customer asking "who changed this, when, and from what", and check whether your code can answer without reading a deploy log.

Top comments (0)