From 23338419df7b0578f2ecfb994f06d3c5f0f57142 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 28 Apr 2026 20:11:47 +0200 Subject: [PATCH] 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. --- .../server/src/utils/inviteService.test.ts | 81 ++++++++++- packages/server/src/utils/inviteService.ts | 131 ++++++++++++++++-- 2 files changed, 197 insertions(+), 15 deletions(-) diff --git a/packages/server/src/utils/inviteService.test.ts b/packages/server/src/utils/inviteService.test.ts index 719f5fa1..63713514 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, 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); + }); +}); diff --git a/packages/server/src/utils/inviteService.ts b/packages/server/src/utils/inviteService.ts index cae2a80f..b4a8bea5 100644 --- a/packages/server/src/utils/inviteService.ts +++ b/packages/server/src/utils/inviteService.ts @@ -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 = {}; + 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)); + }); +}