feat(invites): /api/admin/invites CRUD endpoints
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { authenticate, requireAdmin } from '../utils/auth.js';
|
||||
import {
|
||||
createInvite,
|
||||
listInvites,
|
||||
patchInvite,
|
||||
revokeInvite,
|
||||
reinstateInvite,
|
||||
deleteInvite,
|
||||
listRedemptions,
|
||||
InviteNotFoundError,
|
||||
InviteStateConflictError,
|
||||
InviteValidationError,
|
||||
} from '../utils/inviteService.js';
|
||||
import type {
|
||||
CreateInviteRequest,
|
||||
UpdateInviteRequest,
|
||||
ReinstateInviteRequest,
|
||||
} from '@backspace/shared';
|
||||
|
||||
/**
|
||||
* Admin CRUD routes for invite links. All endpoints sit behind the
|
||||
* `[authenticate, requireAdmin]` preHandler chain — invites are an
|
||||
* instance-local moderation surface and never reach federation.
|
||||
*
|
||||
* The handlers are thin wrappers: they parse the request shape, delegate
|
||||
* to `inviteService` (which owns validation, transactions, and status
|
||||
* derivation), and translate the typed service errors into HTTP statuses:
|
||||
*
|
||||
* - InviteValidationError → 400 Bad Request
|
||||
* - InviteNotFoundError → 404 Not Found
|
||||
* - InviteStateConflictError → 409 Conflict
|
||||
* - anything else → propagates to Fastify's 500 handler
|
||||
*
|
||||
* Response shapes mirror spec §3.1 — see `docs/superpowers/specs/2026-04-28-registration-invites-design.md`.
|
||||
*/
|
||||
export async function invitesRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post<{ Body: CreateInviteRequest }>('/api/admin/invites', {
|
||||
preHandler: [authenticate, requireAdmin],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const invite = createInvite(request.body, request.userId);
|
||||
return reply.code(201).send(invite);
|
||||
} catch (err) {
|
||||
if (err instanceof InviteValidationError) {
|
||||
return reply.code(400).send({ error: err.message, statusCode: 400 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Querystring: { status?: string } }>('/api/admin/invites', {
|
||||
preHandler: [authenticate, requireAdmin],
|
||||
}, async (request, reply) => {
|
||||
const status = request.query.status === 'archived' ? 'archived' : 'active';
|
||||
const invites = listInvites(status);
|
||||
return reply.code(200).send({ invites });
|
||||
});
|
||||
|
||||
app.patch<{ Params: { id: string }; Body: UpdateInviteRequest }>('/api/admin/invites/:id', {
|
||||
preHandler: [authenticate, requireAdmin],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const invite = patchInvite(request.params.id, request.body ?? {});
|
||||
return reply.code(200).send(invite);
|
||||
} catch (err) {
|
||||
if (err instanceof InviteNotFoundError) {
|
||||
return reply.code(404).send({ error: err.message, statusCode: 404 });
|
||||
}
|
||||
if (err instanceof InviteStateConflictError) {
|
||||
return reply.code(409).send({ error: err.message, statusCode: 409 });
|
||||
}
|
||||
if (err instanceof InviteValidationError) {
|
||||
return reply.code(400).send({ error: err.message, statusCode: 400 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string } }>('/api/admin/invites/:id/revoke', {
|
||||
preHandler: [authenticate, requireAdmin],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const invite = revokeInvite(request.params.id);
|
||||
return reply.code(200).send({ invite });
|
||||
} catch (err) {
|
||||
if (err instanceof InviteNotFoundError) {
|
||||
return reply.code(404).send({ error: err.message, statusCode: 404 });
|
||||
}
|
||||
if (err instanceof InviteStateConflictError) {
|
||||
return reply.code(409).send({ error: err.message, statusCode: 409 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { id: string }; Body: ReinstateInviteRequest }>('/api/admin/invites/:id/reinstate', {
|
||||
preHandler: [authenticate, requireAdmin],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
const result = reinstateInvite(request.params.id, request.body ?? {});
|
||||
return reply.code(200).send(result);
|
||||
} catch (err) {
|
||||
if (err instanceof InviteNotFoundError) {
|
||||
return reply.code(404).send({ error: err.message, statusCode: 404 });
|
||||
}
|
||||
if (err instanceof InviteStateConflictError) {
|
||||
return reply.code(409).send({ error: err.message, statusCode: 409 });
|
||||
}
|
||||
if (err instanceof InviteValidationError) {
|
||||
return reply.code(400).send({ error: err.message, statusCode: 400 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/admin/invites/:id', {
|
||||
preHandler: [authenticate, requireAdmin],
|
||||
}, async (request, reply) => {
|
||||
try {
|
||||
deleteInvite(request.params.id);
|
||||
return reply.code(200).send({ success: true });
|
||||
} catch (err) {
|
||||
if (err instanceof InviteNotFoundError) {
|
||||
return reply.code(404).send({ error: err.message, statusCode: 404 });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/admin/invites/:id/redemptions', {
|
||||
preHandler: [authenticate, requireAdmin],
|
||||
}, async (request, reply) => {
|
||||
const redemptions = listRedemptions(request.params.id);
|
||||
return reply.code(200).send({ redemptions });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user