feat(invites): patchInvite + revokeInvite with txn re-derive + foldUsername helper

Both functions wrap their read-modify-write in a Drizzle better-sqlite3
db.transaction((tx) => ...) with in-txn re-fetch so concurrent admin
mutations are serialized by SQLite's writer lock.

- patchInvite: 404 on missing, 409 on revoked, 400 on maxUses < usedCount,
  allows expiresAt to be moved into the past (effective soft-shut).
- revokeInvite: 404 on missing, 409 on already-revoked (explicit reject,
  not silent no-op).

Also extracts foldUsername() to collapse the duplicated
(username, isDeleted) -> display string fold across resolveCreatorUsername,
listInvites, and listRedemptions (deferred refactor from Task 5 review).

Note: the plan's example used db.transaction(cb)() with an IIFE,
which is the raw better-sqlite3 signature. Drizzle's wrapper
returns the callback's return value directly, so we use the
(tx) => ... form consistent with the rest of the codebase
(userDeletion, federation, channels, etc.).

Tests: 36 invite-service tests pass (27 prior + 9 new).
Full server suite: 40 files / 311 tests pass.
This commit is contained in:
Jannis Braun
2026-04-28 20:11:47 +02:00
parent ce5d4c10c8
commit 23338419df
2 changed files with 197 additions and 15 deletions
@@ -21,7 +21,8 @@ vi.mock('../db/index.js', () => ({
schema,
}));
import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions } from './inviteService.js';
import { inviteStatus, generateInviteToken, createInvite, getInviteByToken, listInvites, listRedemptions, InviteValidationError } from './inviteService.js';
import { patchInvite, revokeInvite, InviteStateConflictError, InviteNotFoundError } from './inviteService.js';
import { eq } from 'drizzle-orm';
function applyMigrations(db: Database.Database): void {
@@ -295,3 +296,81 @@ describe('listRedemptions', () => {
expect(list[0]?.isDeleted).toBe(false);
});
});
describe('patchInvite', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
applyMigrations(sqlite);
testDb = drizzle(sqlite, { schema });
});
it('updates name', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'old', maxUses: null, expiresAt: null }, adminId);
const updated = patchInvite(inv.id, { name: 'new' });
expect(updated.name).toBe('new');
});
it('updates maxUses', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'a', maxUses: 5, expiresAt: null }, adminId);
const updated = patchInvite(inv.id, { maxUses: 20 });
expect(updated.maxUses).toBe(20);
});
it('rejects maxUses below current usedCount', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'a', maxUses: 10, expiresAt: null }, adminId);
testDb.update(schema.inviteLinks).set({ usedCount: 7 }).where(eq(schema.inviteLinks.id, inv.id)).run();
expect(() => patchInvite(inv.id, { maxUses: 5 })).toThrow(InviteValidationError);
});
it('throws InviteNotFoundError when id not found', () => {
expect(() => patchInvite('nonexistent', { name: 'x' })).toThrow(InviteNotFoundError);
});
it('throws InviteStateConflictError when invite is revoked', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId);
testDb.update(schema.inviteLinks).set({ revokedAt: Date.now() }).where(eq(schema.inviteLinks.id, inv.id)).run();
expect(() => patchInvite(inv.id, { name: 'x' })).toThrow(InviteStateConflictError);
});
it('allows expiresAt to be moved to past (effective soft-shut)', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'a', maxUses: null, expiresAt: Date.now() + 100_000 }, adminId);
const past = Date.now() - 1000;
const updated = patchInvite(inv.id, { expiresAt: past });
expect(updated.expiresAt).toBe(past);
expect(updated.status).toBe('expired');
});
});
describe('revokeInvite', () => {
beforeEach(() => {
sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
applyMigrations(sqlite);
testDb = drizzle(sqlite, { schema });
});
it('sets revokedAt and returns revoked summary', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId);
const revoked = revokeInvite(inv.id);
expect(revoked.status).toBe('revoked');
expect(revoked.revokedAt).toBeGreaterThan(0);
});
it('throws InviteStateConflictError on already-revoked', () => {
const adminId = seedAdmin();
const inv = createInvite({ name: 'a', maxUses: null, expiresAt: null }, adminId);
revokeInvite(inv.id);
expect(() => revokeInvite(inv.id)).toThrow(InviteStateConflictError);
});
it('throws InviteNotFoundError on missing id', () => {
expect(() => revokeInvite('nonexistent')).toThrow(InviteNotFoundError);
});
});
+117 -14
View File
@@ -3,7 +3,7 @@ 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, InviteRedemption } from '@backspace/shared';
import type { InviteLinkSummary, CreateInviteRequest, InviteRedemption, UpdateInviteRequest } from '@backspace/shared';
/**
* Derived status of an invite link. Mirrors the `InviteStatus` union exported
@@ -111,6 +111,19 @@ function validateExpiresAt(expiresAt: number | null, allowPast: boolean): number
return expiresAt;
}
/**
* Folds a (username, isDeleted) pair into the display string used by
* InviteLinkSummary.createdByUsername / InviteRedemption.currentUsername.
*
* - null username → null (FK unresolvable; should be rare, defensive)
* - isDeleted=1 → 'Deleted User' (matches sanitizeUser convention)
* - else → username
*/
function foldUsername(username: string | null, isDeleted: number | null): string | null {
if (username === null) return null;
return isDeleted === 1 ? 'Deleted User' : username;
}
/**
* Project an `invite_links` row plus the resolved creator-username into the
* shared `InviteLinkSummary` shape. Centralized so list/create/patch/reinstate
@@ -148,9 +161,7 @@ function resolveCreatorUsername(creatorId: string): string | null {
.from(schema.users)
.where(eq(schema.users.id, creatorId))
.get();
if (!u) return null;
if (u.isDeleted === 1) return 'Deleted User';
return u.username;
return foldUsername(u?.username ?? null, u?.isDeleted ?? null);
}
/**
@@ -221,11 +232,7 @@ export function listInvites(filter: 'active' | 'archived'): InviteLinkSummary[]
.all();
const summaries = rows.map(({ invite, creatorUsername, creatorIsDeleted }) => {
const username = creatorUsername === null
? null
: creatorIsDeleted === 1
? 'Deleted User'
: creatorUsername;
const username = foldUsername(creatorUsername, creatorIsDeleted);
return rowToSummary(invite, username);
});
@@ -263,12 +270,108 @@ export function listRedemptions(inviteId: string): InviteRedemption[] {
id: redemption.id,
userId: redemption.userId,
registrantUsername: redemption.registrantUsername,
currentUsername: redemption.userId === null
? null
: currentIsDeleted === 1
? 'Deleted User'
: currentUsername,
currentUsername: redemption.userId === null ? null : foldUsername(currentUsername, currentIsDeleted),
isDeleted: currentIsDeleted === 1,
redeemedAt: redemption.redeemedAt,
}));
}
/**
* Thrown when a mutation targets an invite id that does not exist. Caller
* (HTTP route) maps this to 404 Not Found.
*/
export class InviteNotFoundError extends Error {
constructor() {
super('Invite not found');
this.name = 'InviteNotFoundError';
}
}
/**
* Thrown when a mutation is rejected because the invite's current state
* forbids it (e.g. patching a revoked invite, double-revoking). Caller
* (HTTP route) maps this to 409 Conflict; the message is the user-facing
* copy that surfaces in the toast.
*/
export class InviteStateConflictError extends Error {
constructor(message: string) {
super(message);
this.name = 'InviteStateConflictError';
}
}
/**
* Patch an existing invite's mutable fields. Wrapped in a SQLite transaction
* with an in-txn re-fetch so concurrent admin edits are serialized: the
* second writer sees the first writer's committed state and either applies
* its own delta on top or rejects (e.g. observed-revoked).
*
* Validation rules per spec §3.1:
* - 404 if id not found.
* - 409 if invite is currently revoked (must reinstate first to modify).
* - 400 if maxUses would drop below current usedCount (would retroactively
* exhaust — confusing; admin should use revoke instead).
* - expiresAt may be moved into the past (effective soft-shut → status
* flips to 'expired' on next read).
*
* An empty patch body is a no-op that returns the current summary unchanged.
*/
export function patchInvite(id: string, req: UpdateInviteRequest): InviteLinkSummary {
const db = getDb();
return db.transaction((tx) => {
const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
if (!row) throw new InviteNotFoundError();
if (row.revokedAt !== null) {
throw new InviteStateConflictError('Invite is revoked. Reinstate first to modify.');
}
const updates: Partial<typeof schema.inviteLinks.$inferInsert> = {};
if (req.name !== undefined) updates.name = validateName(req.name);
if (req.maxUses !== undefined) {
const v = validateMaxUses(req.maxUses);
if (v !== null && v < row.usedCount) {
throw new InviteValidationError(
`maxUses (${v}) cannot be less than current usedCount (${row.usedCount})`,
);
}
updates.maxUses = v;
}
if (req.expiresAt !== undefined) {
updates.expiresAt = validateExpiresAt(req.expiresAt, true);
}
if (Object.keys(updates).length === 0) {
// No-op: just return current summary
return rowToSummary(row, resolveCreatorUsername(row.createdBy));
}
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));
});
}
/**
* Revoke an invite. Wrapped in a SQLite transaction with an in-txn re-fetch
* so concurrent revokes are serialized: the first wins, the second sees
* `revokedAt !== null` and throws `InviteStateConflictError` (mapped to 409
* by the route — explicit rejection rather than silent no-op, per spec §3.1).
*
* Token is preserved on revoke; reinstate-from-revoked rotates the token as
* a security boundary (handled in `reinstateInvite`, not here).
*/
export function revokeInvite(id: string): InviteLinkSummary {
const db = getDb();
return db.transaction((tx) => {
const row = tx.select().from(schema.inviteLinks).where(eq(schema.inviteLinks.id, id)).get();
if (!row) throw new InviteNotFoundError();
if (row.revokedAt !== null) {
throw new InviteStateConflictError('Invite is already revoked');
}
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));
});
}