If your product has an account, you have an ongoing agreement with the person behind it, and that agreement says somewhere that you will tell them when it changes. Mine does. munchable.app/terms currently says "Version 1.3. Last updated 3 October 2026" at the top, and that version string is load bearing in four different places.
This is the whole implementation. It is smaller than you would guess, and the interesting parts are the two places it deliberately does nothing.
The source of truth is two exported strings
/**
* Current published versions of the legal documents, as simple numbers you can
* bump by 0.1 (1.0 → 1.1 → 1.2 …). Increment the matching constant whenever you
* materially change `/terms` or `/privacy`.
*
* Account creation records the versions accepted (POST /api/consent →
* public.user_consent). When a constant here is ahead of what a signed-in
* account recorded, the passive re-consent notice shows on their next visit and
* scripts/notify-terms-change.ts can email them. Bumping a value here is all it
* takes to trigger that for everyone still on the previous version.
*/
export const TERMS_VERSION = '1.3';
export const PRIVACY_VERSION = '1.3';
Two, not one, because the documents move independently. A pricing clause in the terms is not a change to the privacy notice, and re-asking everybody about the wrong document trains them to ignore the notice.
They happen to be equal right now, which is a coincidence of history rather than a design.
The first version of this constant was not a number. It was '2026-09-04', a date, and I changed it to '1.1' for a reason worth passing on: a date answers "when did this file last change", and what you need to know is "did this change materially". Those are different questions, and the date version answers the one you did not ask. Fix a typo in a clause and the date moves, which means either you lie about the date or you re-ask several hundred people to accept a comma. A hand-bumped number makes the material-change judgement explicit, at the moment you are making it, in a diff somebody can review.
One row per account, two timestamps, and a copied email
export const userConsent = pgTable('user_consent', {
// The authenticated account → auth.users(id).
userId: uuid('user_id').primaryKey(),
// Account email, mirrored here so terms-change notices can be sent without
// paging the Auth admin API; kept in sync on each sign-in.
email: text('email').notNull(),
// The published legal-document versions accepted (lib/legal-version.ts).
termsVersion: text('terms_version').notNull(),
privacyVersion: text('privacy_version').notNull(),
// First-ever acceptance (audit trail).
acceptedAt: timestamp('accepted_at', { withTimezone: true }).notNull().defaultNow(),
// Latest recording, i.e. acceptance of the versions currently stored above.
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
}).enableRLS();
Three things in there I would defend in review.
The user id is the primary key, not a surrogate id with a unique index on it. There is exactly one consent state per account and the shape should say so, which also means the upsert below needs no extra constraint to conflict on.
The email is a deliberate copy of data that already exists in the auth schema. The reason is in the comment: the one job that needs it is a bulk mailer, and "page the Auth admin API for every user in a list" is a worse thing to own than "keep one column in sync on sign-in". Denormalisation with a written reason and a stated sync point is fine. Denormalisation because it was easier is how you end up emailing an address somebody changed a year ago.
Two timestamps because they answer different questions. accepted_at is the first time this person ever accepted anything, which is the fact an audit wants. updated_at is when they accepted what is in the row now. Keeping both means the upsert has to be careful, and it is:
.onConflictDoUpdate({
target: userConsent.userId,
// Keep `accepted_at` as the first-ever acceptance; `updated_at` tracks
// the latest recording (i.e. acceptance of the version now stored).
set: { email, termsVersion: TERMS_VERSION, privacyVersion: PRIVACY_VERSION, updatedAt: new Date() },
})
accepted_at is absent from the set, which is the entire trick. An onConflictDoUpdate that sets every column is a very easy way to quietly destroy the only audit fact in the table.
The check, and the two cases where it answers "no"
export async function GET(request: NextRequest) {
const user = await getUser(request);
if (!user || user.isAnonymous) return NextResponse.json({ needsReconsent: false });
try {
const rows = await db.select({ terms: userConsent.termsVersion, privacy: userConsent.privacyVersion })
.from(userConsent).where(eq(userConsent.userId, user.id)).limit(1);
const row = rows[0];
const needsReconsent = Boolean(
row && (row.terms !== TERMS_VERSION || row.privacy !== PRIVACY_VERSION),
);
return NextResponse.json({ needsReconsent });
} catch {
return NextResponse.json({ needsReconsent: false });
}
}
row && is the first deliberate nothing. No row means a brand-new account, and a brand-new account must not be told the terms have changed: it accepted the current ones thirty seconds ago via the sign-in upsert, and the row simply has not been read back yet. Absence means new, not stale. Treating a missing row as "needs re-consent" would greet every new user with a notice about a document they have never seen another version of.
The bare catch returning false is the second. If the database is unreachable, the honest options are to fail open or to fail closed, and failing closed here means showing a legal notice to every signed-in user because of a connection wobble. The notice is informational; the agreement is not in doubt. Fail open, show nothing, and the next request will get it right.
Note also !== rather than any ordering comparison. Versions are opaque strings as far as this code is concerned. Comparing them with > would invite a world where going from '1.9' to '1.10' is a downgrade.
The notice records acceptance by appearing
/**
* Passive "we've updated our terms" notice for a signed-in account whose
* recorded Terms/Privacy version is behind the current one. It records
* acceptance automatically: continued use is the acceptance, so there is no
* consent button; the × only hides the notice for this session.
*/
const { data } = await apiFetch<{ needsReconsent?: boolean }>('/api/consent', { method: 'GET' });
if (!active || !data?.needsReconsent) return;
setShow(true);
// Continued use is acceptance, so record the new version now.
void apiFetch('/api/consent', { method: 'POST' }).catch(() => {});
So the POST fires when the notice is shown, not when it is dismissed. The × is cosmetic: it hides a banner, it does not decline anything, and the next launch will not show it again either way.
That is the right design for a notice whose legal theory is "by continuing to use the service you accept the updated terms", and it is the wrong design for anything else. If a change needs explicit consent, a passive banner that accepts on your behalf is not consent, and the flow has to block until the person acts. Being clear about which of those two you are building is more important than any of the code above. This one is the first kind, it says so in the banner text, and the banner links both documents rather than summarising them.
The void ... .catch(() => {}) is there because a failed write should not break a screen. It costs one more notice on the next launch.
The mailer is run by a human on purpose
// FOUNDER-RUN. Emails every account whose recorded Terms/Privacy version is
// behind the current one (lib/legal-version.ts) ...
//
// Run AFTER bumping TERMS_VERSION / PRIVACY_VERSION and deploying the updated
// pages:
// pnpm --filter @munchable/web exec tsx scripts/notify-terms-change.ts
//
// Idempotent-ish: it targets everyone still on an older version, so re-running
// only re-emails those who have not signed in since (which re-records their
// acceptance).
Two details that make this safe to own.
It selects by disagreement with the current constant, not from a list of who was emailed. That makes the audience shrink by itself: everybody who opens the app after the bump records the new version and drops out of the query. Re-running it a day later reaches only the people who have not been back, which is exactly who you wanted to reach twice.
And it is run by hand, after the deploy, because the ordering is the part a cron job would get wrong. Email first and you are pointing people at a page that still shows the old version. There is no scheduler for "when the human finished thinking about the wording".
The bump that prompted this post
Version 1.2 became 1.3 for a change in which no clause moved: the company that publishes Munchable is now named CogniPrep Ltd, where it used to be Sonacode Ltd.
Nothing about the service changed. The other party to the agreement did, and the other party's name is in the first sentence of both documents. That is as material as a change gets, and it is the kind of change that does not feel like one while you are doing a find and replace across a repository. The rule I settled on: if the diff touches who the agreement is between, what we collect, what we are allowed to do with it, or what you pay, it is a bump. Typos and formatting are not.
One edit to one file, and the mobile banner, the web banner, the mailer and the version printed on both documents all move together.
Look at it yourself
- munchable.app/terms and munchable.app/privacy both print the version and date at the top, from the same constants this post is about. They are also the two links the in-app notice points at, which is why the notice has no summary of its own: a summary is one more thing that can disagree with the documents.
- munchable.app/about names the publisher, CogniPrep Ltd, registered in England and Wales. That name is the thing whose change was worth 0.1.
- The privacy notice is also where the consent record is declared as something we hold, which is the part that makes the table above fair game to write about: it is one row of versions and timestamps per account, and nothing in it is health data.
Top comments (0)