feat(invites): listInvites + listRedemptions with creator/current JOINs
Adds two query helpers to inviteService:
- listInvites(filter): single-query LEFT JOIN against users to surface
createdByUsername, with status filtered in TS via the canonical
inviteStatus() derivation. Avoids N+1 the spec calls out (§3.1).
'archived' = expired | exhausted | revoked. Sort: createdAt DESC.
- listRedemptions(inviteId): LEFT JOIN against users via userId to
expose currentUsername alongside the registrantUsername snapshot.
Three null-handling branches per spec §3.1: live (username),
tombstoned ('Deleted User', isDeleted=true), and hard-deleted
(userId null, currentUsername null, isDeleted false).
Sort: redeemedAt DESC.
Also fixes a mistitled DB-miss test in getInviteByToken: the original
'returns null when token not found' used a 24-char string that fails
the format regex *before* the DB lookup. Split into two tests covering
both the format-reject path and the well-formed-but-missing path.
40 files / 302 tests passing.
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user