feat(invites): expose lastRedeemedAt on InviteLinkSummary
This commit is contained in:
@@ -489,6 +489,7 @@ type InviteLinkSummary = {
|
|||||||
createdBy: string;
|
createdBy: string;
|
||||||
createdByUsername: string | null; // 'Deleted User' when creator's isDeleted = 1
|
createdByUsername: string | null; // 'Deleted User' when creator's isDeleted = 1
|
||||||
createdAt: number;
|
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
|
url: string; // server-built `https://<host>/register?invite=<token>` — clients MUST NOT assemble
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -278,6 +278,7 @@ type InviteLinkSummary = {
|
|||||||
createdBy: string;
|
createdBy: string;
|
||||||
createdByUsername: string | null; // 'Deleted User' if creator tombstoned
|
createdByUsername: string | null; // 'Deleted User' if creator tombstoned
|
||||||
createdAt: number;
|
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
|
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', () => {
|
describe('deleteInvite', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sqlite = new Database(':memory:');
|
sqlite = new Database(':memory:');
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import crypto from 'node:crypto';
|
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 { getDb, schema } from '../db/index.js';
|
||||||
import { generateSnowflake } from './snowflake.js';
|
import { generateSnowflake } from './snowflake.js';
|
||||||
import { config } from '../config.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
|
* Project an `invite_links` row plus the resolved creator-username and the
|
||||||
* shared `InviteLinkSummary` shape. Centralized so list/create/patch/reinstate
|
* most-recent redemption timestamp into the shared `InviteLinkSummary` shape.
|
||||||
* all return identically-shaped rows. Status is derived (never stored) per
|
* Centralized so list/create/patch/reinstate all return identically-shaped
|
||||||
* spec §2.1.
|
* 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(
|
function rowToSummary(
|
||||||
row: typeof schema.inviteLinks.$inferSelect,
|
row: typeof schema.inviteLinks.$inferSelect,
|
||||||
createdByUsername: string | null,
|
createdByUsername: string | null,
|
||||||
|
lastRedeemedAt: number | null,
|
||||||
): InviteLinkSummary {
|
): InviteLinkSummary {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -153,10 +159,31 @@ function rowToSummary(
|
|||||||
createdBy: row.createdBy,
|
createdBy: row.createdBy,
|
||||||
createdByUsername,
|
createdByUsername,
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
|
lastRedeemedAt,
|
||||||
url: buildInviteUrl(row.token),
|
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
|
* 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
|
* 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();
|
const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
|
||||||
if (!row) throw new Error('Failed to insert invite');
|
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,
|
invite: schema.inviteLinks,
|
||||||
creatorUsername: schema.users.username,
|
creatorUsername: schema.users.username,
|
||||||
creatorIsDeleted: schema.users.isDeleted,
|
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)
|
.from(schema.inviteLinks)
|
||||||
.leftJoin(schema.users, eq(schema.inviteLinks.createdBy, schema.users.id))
|
.leftJoin(schema.users, eq(schema.inviteLinks.createdBy, schema.users.id))
|
||||||
.orderBy(desc(schema.inviteLinks.createdAt))
|
.orderBy(desc(schema.inviteLinks.createdAt))
|
||||||
.all();
|
.all();
|
||||||
|
|
||||||
const summaries = rows.map(({ invite, creatorUsername, creatorIsDeleted }) => {
|
const summaries = rows.map(({ invite, creatorUsername, creatorIsDeleted, lastRedeemedAt }) => {
|
||||||
const username = foldUsername(creatorUsername, creatorIsDeleted);
|
const username = foldUsername(creatorUsername, creatorIsDeleted);
|
||||||
return rowToSummary(invite, username);
|
return rowToSummary(invite, username, lastRedeemedAt);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (filter === 'active') return summaries.filter(s => s.status === 'active');
|
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) {
|
if (Object.keys(updates).length === 0) {
|
||||||
// No-op: just return current summary
|
// 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();
|
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();
|
const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
|
||||||
if (!updated) throw new Error('Failed to read updated invite');
|
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();
|
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();
|
const updated = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
|
||||||
if (!updated) throw new Error('Failed to read updated invite');
|
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 {
|
return {
|
||||||
invite: rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx)),
|
invite: rowToSummary(updated, resolveCreatorUsername(updated.createdBy, tx), resolveLastRedeemedAt(updated.id, tx)),
|
||||||
tokenRotated,
|
tokenRotated,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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). */
|
/** 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;
|
createdByUsername: string | null;
|
||||||
createdAt: number;
|
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. */
|
/** Server-constructed full URL, e.g. `https://host.example/register?invite=<token>`. Clients must NOT assemble this themselves. */
|
||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user