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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user