feat(invites): expose lastRedeemedAt on InviteLinkSummary

This commit is contained in:
Jannis Braun
2026-04-29 02:33:49 +02:00
parent 5abe13166a
commit 33547038f4
5 changed files with 140 additions and 12 deletions
+1
View File
@@ -489,6 +489,7 @@ type InviteLinkSummary = {
createdBy: string;
createdByUsername: string | null; // 'Deleted User' when creator's isDeleted = 1
createdAt: number;
lastRedeemedAt: number | null; // epoch ms of most recent redemption; null when usedCount = 0
url: string; // server-built `https://<host>/register?invite=<token>` — clients MUST NOT assemble
};
+1
View File
@@ -278,6 +278,7 @@ type InviteLinkSummary = {
createdBy: string;
createdByUsername: string | null; // 'Deleted User' if creator tombstoned
createdAt: number;
lastRedeemedAt: number | null; // epoch ms of most recent redemption; null when usedCount = 0
url: string; // server-built `https://<host>/register?invite=<token>` — clients MUST NOT assemble
};
@@ -595,6 +595,101 @@ describe('redeemInvite', () => {
});
});
describe('lastRedeemedAt', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
applyMigrations(sqlite);
testDb = drizzle(sqlite, { schema });
});
it('listInvites returns null lastRedeemedAt when invite has zero redemptions', () => {
const adminId = seedAdmin();
createInvite({ name: 'no-redeems', maxUses: null, expiresAt: null }, adminId);
const list = listInvites('active');
expect(list).toHaveLength(1);
expect(list[0]?.lastRedeemedAt).toBeNull();
});
it('listInvites returns the max redeemed_at when invite has multiple redemptions', () => {
const adminId = seedAdmin();
const invite = createInvite({ name: 'multi', maxUses: null, expiresAt: null }, adminId);
const t1 = Date.now() - 2000;
const t2 = Date.now() - 1000;
const t3 = Date.now();
testDb.insert(schema.users).values({ id: 'u1', username: 'alice', passwordHash: 'x', createdAt: t1 }).run();
testDb.insert(schema.users).values({ id: 'u2', username: 'bob', passwordHash: 'x', createdAt: t2 }).run();
testDb.insert(schema.users).values({ id: 'u3', username: 'carol', passwordHash: 'x', createdAt: t3 }).run();
testDb.insert(schema.inviteRedemptions).values({ id: 'r1', inviteId: invite.id, userId: 'u1', registrantUsername: 'alice', redeemedAt: t1 }).run();
testDb.insert(schema.inviteRedemptions).values({ id: 'r2', inviteId: invite.id, userId: 'u2', registrantUsername: 'bob', redeemedAt: t3 }).run();
testDb.insert(schema.inviteRedemptions).values({ id: 'r3', inviteId: invite.id, userId: 'u3', registrantUsername: 'carol', redeemedAt: t2 }).run();
const list = listInvites('active');
expect(list).toHaveLength(1);
expect(list[0]?.lastRedeemedAt).toBe(t3);
});
it('listInvites correctly attributes lastRedeemedAt to the right invite when multiple invites exist', () => {
const adminId = seedAdmin();
const invA = createInvite({ name: 'A', maxUses: null, expiresAt: null }, adminId);
const invB = createInvite({ name: 'B', maxUses: null, expiresAt: null }, adminId);
const tA = Date.now() - 5000;
const tB = Date.now() - 1000;
testDb.insert(schema.users).values({ id: 'uA', username: 'alice', passwordHash: 'x', createdAt: tA }).run();
testDb.insert(schema.users).values({ id: 'uB', username: 'bob', passwordHash: 'x', createdAt: tB }).run();
testDb.insert(schema.inviteRedemptions).values({ id: 'rA', inviteId: invA.id, userId: 'uA', registrantUsername: 'alice', redeemedAt: tA }).run();
testDb.insert(schema.inviteRedemptions).values({ id: 'rB', inviteId: invB.id, userId: 'uB', registrantUsername: 'bob', redeemedAt: tB }).run();
const list = listInvites('active');
const summaryA = list.find(i => i.id === invA.id);
const summaryB = list.find(i => i.id === invB.id);
expect(summaryA?.lastRedeemedAt).toBe(tA);
expect(summaryB?.lastRedeemedAt).toBe(tB);
});
it('getInviteByToken returns the raw row (not InviteLinkSummary) — lastRedeemedAt is not on the raw row type', () => {
// getInviteByToken intentionally returns the raw $inferSelect row for use
// by the registration flow, not an InviteLinkSummary. Confirm the raw row
// exists and can be fetched — the absence of lastRedeemedAt on the type is
// enforced by TypeScript at compile time.
const adminId = seedAdmin();
const created = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId);
const rawRow = getInviteByToken(created.token);
expect(rawRow).not.toBeNull();
expect(rawRow?.id).toBe(created.id);
});
it('after redeemInvite, a subsequent listInvites reflects the updated lastRedeemedAt', () => {
const adminId = seedAdmin();
const invite = createInvite({ name: 'redeem-test', maxUses: 5, expiresAt: null }, adminId);
// Confirm null before any redemption
const before = listInvites('active');
expect(before.find(i => i.id === invite.id)?.lastRedeemedAt).toBeNull();
const newUserId = 'redeemer-2';
const tBefore = Date.now();
redeemInvite(invite.token, () => {
testDb.insert(schema.users).values({
id: newUserId,
username: 'redeemer2',
passwordHash: 'x',
createdAt: Date.now(),
}).run();
return { id: newUserId, username: 'redeemer2' };
});
const tAfter = Date.now();
const after = listInvites('active');
const summary = after.find(i => i.id === invite.id);
expect(summary?.lastRedeemedAt).not.toBeNull();
expect(summary?.lastRedeemedAt).toBeGreaterThanOrEqual(tBefore);
expect(summary?.lastRedeemedAt).toBeLessThanOrEqual(tAfter);
});
});
describe('deleteInvite', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
+41 -12
View File
@@ -1,5 +1,5 @@
import crypto from 'node:crypto';
import { eq, desc } from 'drizzle-orm';
import { eq, desc, sql } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from './snowflake.js';
import { config } from '../config.js';
@@ -132,14 +132,20 @@ function foldUsername(username: string | null, isDeleted: number | null): string
}
/**
* Project an `invite_links` row plus the resolved creator-username into the
* shared `InviteLinkSummary` shape. Centralized so list/create/patch/reinstate
* all return identically-shaped rows. Status is derived (never stored) per
* spec §2.1.
* Project an `invite_links` row plus the resolved creator-username and the
* most-recent redemption timestamp into the shared `InviteLinkSummary` shape.
* Centralized so list/create/patch/reinstate all return identically-shaped
* rows. Status is derived (never stored) per spec §2.1.
*
* `lastRedeemedAt` is `null` when the invite has zero redemptions. Callers
* that run inside a transaction pass the `MAX(redeemed_at)` they read inside
* the same transaction handle; `listInvites` bakes this into the SELECT
* projection as a correlated subquery.
*/
function rowToSummary(
row: typeof schema.inviteLinks.$inferSelect,
createdByUsername: string | null,
lastRedeemedAt: number | null,
): InviteLinkSummary {
return {
id: row.id,
@@ -153,10 +159,31 @@ function rowToSummary(
createdBy: row.createdBy,
createdByUsername,
createdAt: row.createdAt,
lastRedeemedAt,
url: buildInviteUrl(row.token),
};
}
/**
* Query `MAX(redeemed_at)` for a single invite from the `invite_redemptions`
* table. Returns `null` when there are no redemption rows for this invite.
*
* Accepts an optional Drizzle handle so mutation callers inside a transaction
* body can read the max from the same logical transaction as their surrounding
* writes. Defaults to the outer `getDb()` for non-txn callers.
*/
function resolveLastRedeemedAt(
inviteId: string,
dbHandle: ReturnType<typeof getDb> = getDb(),
): number | null {
const result = dbHandle
.select({ maxAt: sql<number | null>`MAX(${schema.inviteRedemptions.redeemedAt})` })
.from(schema.inviteRedemptions)
.where(eq(schema.inviteRedemptions.inviteId, inviteId))
.get();
return result?.maxAt ?? null;
}
/**
* Resolve the username to display for an invite's creator. Returns the live
* username, `'Deleted User'` for tombstoned accounts (spec §3.1, §4.1), or
@@ -206,7 +233,8 @@ export function createInvite(req: CreateInviteRequest, creatorId: string): Invit
const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
if (!row) throw new Error('Failed to insert invite');
return rowToSummary(row, resolveCreatorUsername(creatorId));
// Freshly created invite: zero redemptions, so lastRedeemedAt is always null.
return rowToSummary(row, resolveCreatorUsername(creatorId), null);
}
/**
@@ -238,15 +266,16 @@ export function listInvites(filter: 'active' | 'archived'): InviteLinkSummary[]
invite: schema.inviteLinks,
creatorUsername: schema.users.username,
creatorIsDeleted: schema.users.isDeleted,
lastRedeemedAt: sql<number | null>`(SELECT MAX(${schema.inviteRedemptions.redeemedAt}) FROM ${schema.inviteRedemptions} WHERE ${schema.inviteRedemptions.inviteId} = ${schema.inviteLinks.id})`,
})
.from(schema.inviteLinks)
.leftJoin(schema.users, eq(schema.inviteLinks.createdBy, schema.users.id))
.orderBy(desc(schema.inviteLinks.createdAt))
.all();
const summaries = rows.map(({ invite, creatorUsername, creatorIsDeleted }) => {
const summaries = rows.map(({ invite, creatorUsername, creatorIsDeleted, lastRedeemedAt }) => {
const username = foldUsername(creatorUsername, creatorIsDeleted);
return rowToSummary(invite, username);
return rowToSummary(invite, username, lastRedeemedAt);
});
if (filter === 'active') return summaries.filter(s => s.status === 'active');
@@ -355,13 +384,13 @@ export function patchInvite(id: string, req: UpdateInviteRequest): InviteLinkSum
if (Object.keys(updates).length === 0) {
// No-op: just return current summary
return rowToSummary(row, resolveCreatorUsername(row.createdBy, tx));
return rowToSummary(row, resolveCreatorUsername(row.createdBy, tx), resolveLastRedeemedAt(row.id, tx));
}
tx.update(schema.inviteLinks).set(updates).where(eq(schema.inviteLinks.id, id)).run();
const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
if (!updated) throw new Error('Failed to read updated invite');
return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx));
return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx), resolveLastRedeemedAt(updated.id, tx));
});
}
@@ -385,7 +414,7 @@ export function revokeInvite(id: string): InviteLinkSummary {
tx.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, id)).run();
const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
if (!updated) throw new Error('Failed to read updated invite');
return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx));
return rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx), resolveLastRedeemedAt(updated.id, tx));
});
}
@@ -462,7 +491,7 @@ export function reinstateInvite(id: string, req: ReinstateInviteRequest): Reinst
}
return {
invite: rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx)),
invite: rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx), resolveLastRedeemedAt(updated.id, tx)),
tokenRotated,
};
});
+2
View File
@@ -1151,6 +1151,8 @@ export interface InviteLinkSummary {
/** Joined from users.username at read time. `'Deleted User'` when the creator's account is tombstoned. `null` only if the FK is somehow unresolvable (defensive). */
createdByUsername: string | null;
createdAt: number;
/** Epoch ms of the most recent redemption; `null` when the invite has zero redemptions. */
lastRedeemedAt: number | null;
/** Server-constructed full URL, e.g. `https://host.example/register?invite=<token>`. Clients must NOT assemble this themselves. */
url: string;
}