From 33547038f4a6a11fa93b9d3450d7e09c0e6969ef Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 29 Apr 2026 02:33:49 +0200 Subject: [PATCH] feat(invites): expose lastRedeemedAt on InviteLinkSummary --- docs/systems/admin.md | 1 + docs/systems/api.md | 1 + .../server/src/utils/inviteService.test.ts | 95 +++++++++++++++++++ packages/server/src/utils/inviteService.ts | 53 ++++++++--- packages/shared/src/types.ts | 2 + 5 files changed, 140 insertions(+), 12 deletions(-) diff --git a/docs/systems/admin.md b/docs/systems/admin.md index d1b3b939..46d62734 100644 --- a/docs/systems/admin.md +++ b/docs/systems/admin.md @@ -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:///register?invite=` — clients MUST NOT assemble }; diff --git a/docs/systems/api.md b/docs/systems/api.md index 7d9f0792..b6eeb73f 100644 --- a/docs/systems/api.md +++ b/docs/systems/api.md @@ -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:///register?invite=` — clients MUST NOT assemble }; diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index b121bf16..be1dac35 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -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:'); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index 6130a800..57cb1b1e 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -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 = getDb(), +): number | null { + const result = dbHandle + .select({ maxAt: sql`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`(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, }; }); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index e392a576..f979fdd7 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -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=`. Clients must NOT assemble this themselves. */ url: string; }