feat(invites): expose lastRedeemedAt on InviteLinkSummary
This commit is contained in:
@@ -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:');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user