diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 05fa529d..719f5fa1 100644 --- a/packages/server/src/utils/inviteService.test.ts +++ b/packages/server/src/utils/inviteService.test.ts @@ -21,7 +21,8 @@ vi.mock('../db/index.js', () => ({ schema, })); -import { inviteStatus, generateInviteToken, createInvite, getInviteByToken } from './inviteService.js'; +import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions } from './inviteService.js'; +import { eq } from 'drizzle-orm'; function applyMigrations(db: Database.Database): void { const migrationsDir = path.resolve(__dirname, '../../drizzle'); @@ -168,7 +169,129 @@ describe('getInviteByToken', () => { expect(found?.id).toBe(created.id); }); - it('returns null when token not found', () => { - expect(getInviteByToken('nonexistent_token_aaaaaa')).toBeNull(); + it('returns null when token has invalid format', () => { + expect(getInviteByToken('tooshort')).toBeNull(); + expect(getInviteByToken('nonexistent_token_aaaaaa')).toBeNull(); // 24 chars + }); + + it('returns null when token is well-formed but not in DB', () => { + expect(getInviteByToken('aaaaaaaaaaaaaaaaaaaaaa')).toBeNull(); // 22 chars, valid format + }); +}); + +describe('listInvites', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('returns active invites only with status=active', () => { + const adminId = seedAdmin(); + const a = createInvite({ name: 'active1', maxUses: null, expiresAt: null }, adminId); + const b = createInvite({ name: 'active2', maxUses: 1, expiresAt: null }, adminId); + // Manually exhaust b + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, b.id)).run(); + + const list = listInvites('active'); + expect(list.map(i => i.id)).toEqual([a.id]); + }); + + it('returns archived invites only with status=archived', () => { + const adminId = seedAdmin(); + createInvite({ name: 'active', maxUses: null, expiresAt: null }, adminId); + const exhausted = createInvite({ name: 'exhausted', maxUses: 1, expiresAt: null }, adminId); + testDb.update(schema.inviteLinks).set({ usedCount: 1 }).where(eq(schema.inviteLinks.id, exhausted.id)).run(); + const revoked = createInvite({ name: 'revoked', maxUses: null, expiresAt: null }, adminId); + testDb.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, revoked.id)).run(); + + const list = listInvites('archived'); + expect(list.map(i => i.id).sort()).toEqual([exhausted.id, revoked.id].sort()); + expect(list.find(i => i.id === exhausted.id)?.status).toBe('exhausted'); + expect(list.find(i => i.id === revoked.id)?.status).toBe('revoked'); + }); + + it('JOIN surfaces createdByUsername; tombstoned creator -> "Deleted User"', () => { + const adminId = seedAdmin(); + createInvite({ name: 'i1', maxUses: null, expiresAt: null }, adminId); + // Tombstone admin + testDb.update(schema.users).set({ isDeleted: 1, username: '!deleted:' + adminId }).where(eq(schema.users.id, adminId)).run(); + + const list = listInvites('active'); + expect(list[0]?.createdByUsername).toBe('Deleted User'); + }); + + it('sorts by createdAt DESC', async () => { + const adminId = seedAdmin(); + const a = createInvite({ name: 'first', maxUses: null, expiresAt: null }, adminId); + await new Promise(r => setTimeout(r, 5)); + const b = createInvite({ name: 'second', maxUses: null, expiresAt: null }, adminId); + const list = listInvites('active'); + expect(list.map(i => i.id)).toEqual([b.id, a.id]); + }); +}); + +describe('listRedemptions', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + }); + + it('returns redemption rows with currentUsername joined', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'i', maxUses: null, expiresAt: null }, adminId); + const userId = 'user-1'; + testDb.insert(schema.users).values({ id: userId, username: 'alice', passwordHash: 'x', createdAt: Date.now() }).run(); + testDb.insert(schema.inviteRedemptions).values({ + id: 'red-1', + inviteId: invite.id, + userId, + registrantUsername: 'alice', + redeemedAt: Date.now(), + }).run(); + + const list = listRedemptions(invite.id); + expect(list).toHaveLength(1); + expect(list[0]?.registrantUsername).toBe('alice'); + expect(list[0]?.currentUsername).toBe('alice'); + expect(list[0]?.isDeleted).toBe(false); + }); + + it('marks tombstoned users with currentUsername="Deleted User" and isDeleted=true', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'i', maxUses: null, expiresAt: null }, adminId); + const userId = 'user-2'; + testDb.insert(schema.users).values({ id: userId, username: 'bob', passwordHash: 'x', isDeleted: 1, createdAt: Date.now() }).run(); + testDb.insert(schema.inviteRedemptions).values({ + id: 'red-2', + inviteId: invite.id, + userId, + registrantUsername: 'bob', + redeemedAt: Date.now(), + }).run(); + + const list = listRedemptions(invite.id); + expect(list[0]?.currentUsername).toBe('Deleted User'); + expect(list[0]?.isDeleted).toBe(true); + }); + + it('handles null userId (hard-deleted user)', () => { + const adminId = seedAdmin(); + const invite = createInvite({ name: 'i', maxUses: null, expiresAt: null }, adminId); + testDb.insert(schema.inviteRedemptions).values({ + id: 'red-3', + inviteId: invite.id, + userId: null, + registrantUsername: 'ghost', + redeemedAt: Date.now(), + }).run(); + + const list = listRedemptions(invite.id); + expect(list[0]?.userId).toBeNull(); + expect(list[0]?.currentUsername).toBeNull(); + expect(list[0]?.isDeleted).toBe(false); }); }); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index 97efde7b..cae2a80f 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -1,9 +1,9 @@ import crypto from 'node:crypto'; -import { eq } from 'drizzle-orm'; +import { eq, desc } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { generateSnowflake } from './snowflake.js'; import { config } from '../config.js'; -import type { InviteLinkSummary, CreateInviteRequest } from '@backspace/shared'; +import type { InviteLinkSummary, CreateInviteRequest, InviteRedemption } from '@backspace/shared'; /** * Derived status of an invite link. Mirrors the `InviteStatus` union exported @@ -197,3 +197,78 @@ export function getInviteByToken(token: string): typeof schema.inviteLinks.$infe const row = db.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.token, token)).get(); return row ?? null; } + +/** + * List invites filtered by lifecycle state. `'active'` returns only rows whose + * derived status is `active`; `'archived'` returns rows in `expired`, + * `exhausted`, or `revoked`. The status is derived in TS (single source of + * truth: `inviteStatus()`), so we fetch all rows then filter — see spec §6.3 + * (no per-instance invite policy / janitor) for why this is acceptable at v1 + * scale; switch to a SQL-side filter only if instances accumulate thousands of + * invites. The LEFT JOIN against `users` resolves `createdByUsername` in a + * single query, avoiding the N+1 the spec calls out (§3.1). + */ +export function listInvites(filter: 'active' | 'archived'): InviteLinkSummary[] { + const db = getDb(); + const rows = db.select({ + invite: schema.inviteLinks, + creatorUsername: schema.users.username, + creatorIsDeleted: schema.users.isDeleted, + }) + .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 username = creatorUsername === null + ? null + : creatorIsDeleted === 1 + ? 'Deleted User' + : creatorUsername; + return rowToSummary(invite, username); + }); + + if (filter === 'active') return summaries.filter(s => s.status === 'active'); + return summaries.filter(s => s.status !== 'active'); +} + +/** + * List redemptions for one invite, newest first. The LEFT JOIN against `users` + * via `userId` surfaces the live username so the UI can render + * "registered as alice (now Anastasia)" — the snapshot in `registrantUsername` + * stays forensically stable while `currentUsername` reflects the live state. + * + * Three null-handling branches per spec §3.1: + * - live user → `currentUsername = users.username`, `isDeleted = false` + * - tombstoned user → `currentUsername = 'Deleted User'`, `isDeleted = true` + * - hard-deleted user → `userId = null`, `currentUsername = null`, + * `isDeleted = false` (the row is genuinely gone, not + * soft-deleted; "Deleted User" would be misleading) + */ +export function listRedemptions(inviteId: string): InviteRedemption[] { + const db = getDb(); + const rows = db.select({ + redemption: schema.inviteRedemptions, + currentUsername: schema.users.username, + currentIsDeleted: schema.users.isDeleted, + }) + .from(schema.inviteRedemptions) + .leftJoin(schema.users, eq(schema.inviteRedemptions.userId, schema.users.id)) + .where(eq(schema.inviteRedemptions.inviteId, inviteId)) + .orderBy(desc(schema.inviteRedemptions.redeemedAt)) + .all(); + + return rows.map(({ redemption, currentUsername, currentIsDeleted }) => ({ + id: redemption.id, + userId: redemption.userId, + registrantUsername: redemption.registrantUsername, + currentUsername: redemption.userId === null + ? null + : currentIsDeleted === 1 + ? 'Deleted User' + : currentUsername, + isDeleted: currentIsDeleted === 1, + redeemedAt: redemption.redeemedAt, + })); +}