UNPKG

better-auth-waitlist

Version:

A lightweight Better Auth plugin for waitlist management with admin approval workflows, domain restrictions, and customizable validation.

701 lines (697 loc) 22.3 kB
// src/index.ts import { createAuthEndpoint, sessionMiddleware } from "better-auth/api"; import { mergeSchema } from "better-auth/db"; import { z } from "zod/v3"; // src/error-codes.ts var HTTP_STATUS_CODES = { OK: 200, CREATED: 201, UNAUTHORIZED: 401, FORBIDDEN: 403, NOT_FOUND: 404, UNPROCESSABLE_ENTITY: 422, TOO_MANY_REQUESTS: 429 }; var HTTP_STATUS_CODE_MESSAGES = { [HTTP_STATUS_CODES.OK]: "OK", [HTTP_STATUS_CODES.CREATED]: "Created", [HTTP_STATUS_CODES.UNAUTHORIZED]: "Unauthorized", [HTTP_STATUS_CODES.FORBIDDEN]: "Forbidden", [HTTP_STATUS_CODES.NOT_FOUND]: "Not Found", [HTTP_STATUS_CODES.UNPROCESSABLE_ENTITY]: "Unprocessable Entity", [HTTP_STATUS_CODES.TOO_MANY_REQUESTS]: "Too Many Requests" }; var WAITLIST_ERROR_CODES = { // Entry validation errors EMAIL_ALREADY_IN_WAITLIST: "email_already_in_waitlist", DOMAIN_NOT_ALLOWED: "domain_not_allowed", INVALID_ENTRY: "invalid_entry", // Capacity and limits WAITLIST_FULL: "waitlist_full", RATE_LIMIT_EXCEEDED: "rate_limit_exceeded", // Configuration errors WAITLIST_NOT_ENABLED: "waitlist_not_enabled", // Authorization errors UNAUTHORIZED: "unauthorized", // Permission errors FORBIDDEN: "permission_denied", // Data errors WAITLIST_ENTRY_NOT_FOUND: "waitlist_entry_not_found" }; var WAITLIST_ERROR_MESSAGES = { [WAITLIST_ERROR_CODES.EMAIL_ALREADY_IN_WAITLIST]: "Email already in waitlist", [WAITLIST_ERROR_CODES.DOMAIN_NOT_ALLOWED]: "Email domain not allowed", [WAITLIST_ERROR_CODES.INVALID_ENTRY]: "Invalid entry data", [WAITLIST_ERROR_CODES.WAITLIST_FULL]: "Waitlist is full", [WAITLIST_ERROR_CODES.RATE_LIMIT_EXCEEDED]: "Too many requests, please try again later", [WAITLIST_ERROR_CODES.WAITLIST_NOT_ENABLED]: "Waitlist is not enabled", [WAITLIST_ERROR_CODES.UNAUTHORIZED]: "You are not authorized to perform this action", [WAITLIST_ERROR_CODES.FORBIDDEN]: "Not enough permissions to perform this action", [WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND]: "Waitlist entry not found" }; // src/schema.ts var WAITLIST_MODEL_NAME = "waitlist"; var WAITLIST_STATUS = { PENDING: "pending", APPROVED: "approved", REJECTED: "rejected" }; var schema = { waitlist: { fields: { email: { type: "string", required: true, unique: true }, status: { type: "string", required: true, input: false, defaultValue: WAITLIST_STATUS.PENDING }, requestedAt: { type: "date", required: false, input: false, defaultValue: () => /* @__PURE__ */ new Date() }, processedAt: { type: "date", required: false, input: false }, processedBy: { type: "string", references: { model: "user", field: "id", onDelete: "no action" }, required: false, input: false } } } }; // src/client.ts var waitlistClient = (options) => { console.warn(options); return { id: "waitlist", $InferServerPlugin: {} }; }; // src/index.ts var waitlist = (options) => { const opts = { enabled: options?.enabled ?? false, schema: options?.schema, allowedDomains: options?.allowedDomains, disableSignInAndSignUp: options?.disableSignInAndSignUp ?? false, maximumWaitlistParticipants: options?.maximumWaitlistParticipants ?? void 0, autoApprove: options?.autoApprove ?? false, validateEntry: options?.validateEntry, onStatusChange: options?.onStatusChange, onJoinRequest: options?.onJoinRequest, notifications: options?.notifications, rateLimit: options?.rateLimit, additionalFields: options?.additionalFields ?? {}, canManageWaitlist: options?.canManageWaitlist }; const baseSchema = { waitlist: { ...schema.waitlist, fields: { ...schema.waitlist.fields } } }; const mergedSchema = mergeSchema(baseSchema, opts.schema); mergedSchema.waitlist.fields = { ...mergedSchema.waitlist.fields, ...opts.additionalFields }; const model = Object.keys(mergedSchema)[0]; return { id: "waitlist", schema: mergedSchema, $ERROR_CODES: WAITLIST_ERROR_CODES, endpoints: { join: createAuthEndpoint( "/waitlist/join", { method: "POST", body: convertAdditionalFieldsToZodSchema({ ...opts.additionalFields, email: { type: "string", required: true } }) }, async (ctx) => { if (!opts.enabled) { throw ctx.error(HTTP_STATUS_CODES.FORBIDDEN, { code: WAITLIST_ERROR_CODES.WAITLIST_NOT_ENABLED, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.WAITLIST_NOT_ENABLED] }); } const { email, ...everythingElse } = ctx.body; const found = await ctx.context.adapter.findOne({ model, where: [ { field: "email", value: email, operator: "eq" } ] }); if (found) { throw ctx.error(HTTP_STATUS_CODES.FORBIDDEN, { code: WAITLIST_ERROR_CODES.EMAIL_ALREADY_IN_WAITLIST, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.EMAIL_ALREADY_IN_WAITLIST] }); } let count = null; if (opts.maximumWaitlistParticipants) { count = await ctx.context.adapter.count({ model, where: [ { field: "status", operator: "eq", value: WAITLIST_STATUS.PENDING } ] }); if (count >= opts.maximumWaitlistParticipants) { throw ctx.error(HTTP_STATUS_CODES.FORBIDDEN, { code: WAITLIST_ERROR_CODES.WAITLIST_FULL, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.WAITLIST_FULL] }); } } if (opts.validateEntry) { const isValid = await opts.validateEntry({ email, ...everythingElse }); if (!isValid) { throw ctx.error(HTTP_STATUS_CODES.FORBIDDEN, { code: WAITLIST_ERROR_CODES.INVALID_ENTRY, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.INVALID_ENTRY] }); } } if (opts.allowedDomains) { const isAllowedDomain = opts.allowedDomains.some( (domain) => email.endsWith(domain) ); if (!isAllowedDomain) { throw ctx.error(HTTP_STATUS_CODES.FORBIDDEN, { code: WAITLIST_ERROR_CODES.DOMAIN_NOT_ALLOWED, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.DOMAIN_NOT_ALLOWED] }); } } const newJoinRequest = await ctx.context.adapter.create({ model, data: { email, status: WAITLIST_STATUS.PENDING, requestedAt: /* @__PURE__ */ new Date(), ...everythingElse } }); opts.onJoinRequest?.({ request: newJoinRequest }); return ctx.json({ id: newJoinRequest.id, email: newJoinRequest.email, requestedAt: newJoinRequest.requestedAt, ...everythingElse }); } ), list: createAuthEndpoint( "/waitlist/list", { method: "GET", use: [sessionMiddleware], query: z.object({ page: z.string().or(z.number()).optional(), limit: z.string().or(z.number()).optional(), status: z.enum([ WAITLIST_STATUS.APPROVED, WAITLIST_STATUS.PENDING, WAITLIST_STATUS.REJECTED ]).describe("The status of the waitlist entries to filter by").optional(), sortBy: z.enum(["requestedAt", "status"]).describe("The field to sort by").optional(), direction: z.enum(["asc", "desc"]).describe("The direction to sort by").optional() }), metadata: { openapi: { responses: { 200: { description: "List of waitlist requests", content: { "application/json": { schema: { type: "object", properties: { waitlist: { type: "array", items: { $ref: "#/components/schemas/WaitlistEntry" } }, total: { type: "number" }, limit: { type: ["number", "undefined"] }, page: { type: ["number", "undefined"] } } } } } } } } } }, async (ctx) => { const { user } = ctx.context.session; if (!user) { ctx.error(HTTP_STATUS_CODES.UNAUTHORIZED, { code: WAITLIST_ERROR_CODES.UNAUTHORIZED, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.UNAUTHORIZED] }); } if (opts.canManageWaitlist) { const hasAccess = await opts.canManageWaitlist(user); if (!hasAccess) { throw ctx.error("FORBIDDEN", { code: WAITLIST_ERROR_CODES.FORBIDDEN, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.FORBIDDEN] }); } } else { if (user.role !== "admin") { throw ctx.error("FORBIDDEN", { code: WAITLIST_ERROR_CODES.FORBIDDEN, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.FORBIDDEN] }); } } const { page = 1, limit: rawLimit = 10, status, sortBy = "requestedAt", direction = "desc" } = ctx.query; const offset = (page === 1 ? 0 : Number(page) - 1) * Number(rawLimit); const limit = Number(rawLimit); const sortByField = sortBy ?? "requestedAt"; const sortDirection = direction === "desc" ? "desc" : "asc"; const filters = []; if (status) { filters.push({ field: "status", operator: "eq", value: status }); } const totalCount = await ctx.context.adapter.count({ model, where: filters }); const waitlistEntries = await ctx.context.adapter.findMany({ model, where: filters, limit, offset, sortBy: { field: sortByField, direction: sortDirection } }); return ctx.json({ data: waitlistEntries, page, limit, total: totalCount }); } ), findOne: createAuthEndpoint( "/waitlist/request/find", { method: "GET", query: z.object({ id: z.string() }), use: [sessionMiddleware], metadata: { openapi: { responses: { 200: { description: "Waitlist entry details" }, 401: { description: "You are not authorized to perform this action" }, 403: { description: "Not enough permissions to perform this action" }, 404: { description: "Waitlist entry not found" } } } } }, async (ctx) => { const { user } = ctx.context.session; if (!user) { throw ctx.error(HTTP_STATUS_CODES.UNAUTHORIZED, { code: WAITLIST_ERROR_CODES.UNAUTHORIZED, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.UNAUTHORIZED] }); } if (opts.canManageWaitlist) { const hasAccess = await opts.canManageWaitlist(user); if (!hasAccess) { throw ctx.error(HTTP_STATUS_CODES.FORBIDDEN, { code: WAITLIST_ERROR_CODES.FORBIDDEN, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.FORBIDDEN] }); } } else { if (user.role !== "admin") { throw ctx.error(HTTP_STATUS_CODES.FORBIDDEN, { code: WAITLIST_ERROR_CODES.FORBIDDEN, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.FORBIDDEN] }); } } const { id } = ctx.query; const waitlistEntry = await ctx.context.adapter.findOne({ model, where: [ { field: "id", operator: "eq", value: id } ] }); if (!waitlistEntry) { throw ctx.error(HTTP_STATUS_CODES.NOT_FOUND, { code: WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND] }); } return ctx.json(waitlistEntry, { status: HTTP_STATUS_CODES.OK, statusText: HTTP_STATUS_CODE_MESSAGES[HTTP_STATUS_CODES.OK] }); } ), checkRequestStatus: createAuthEndpoint( "/waitlist/request/check-status", { method: "GET", query: z.object({ email: z.string().email() }), metadata: { openapi: { responses: { 200: { description: "Waitlist entry status" }, 404: { description: "Waitlist entry not found" } } } } }, async (ctx) => { const { email } = ctx.query; const waitlistEntry = await ctx.context.adapter.findOne({ model, where: [ { field: "email", operator: "eq", value: email } ] }); if (!waitlistEntry) { throw ctx.error(HTTP_STATUS_CODES.NOT_FOUND, { code: WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND] }); } return ctx.json({ status: waitlistEntry?.status, requestedAt: waitlistEntry?.requestedAt }); } ), approveRequest: createAuthEndpoint( "/waitlist/request/approve", { method: "POST", body: z.object({ id: z.string() }), use: [sessionMiddleware], metadata: { openapi: { responses: { 200: { description: "Waitlist entry approved" }, 401: { description: "You are not authorized to perform this action" }, 403: { description: "Not enough permissions to perform this action" }, 404: { description: "Waitlist entry not found" } } } } }, async (ctx) => { const { user } = ctx.context.session; if (!user) { throw ctx.error(HTTP_STATUS_CODES.UNAUTHORIZED, { code: WAITLIST_ERROR_CODES.UNAUTHORIZED, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.UNAUTHORIZED] }); } if (opts.canManageWaitlist) { const hasAccess = await opts.canManageWaitlist(user); if (!hasAccess) { throw ctx.error(HTTP_STATUS_CODES.FORBIDDEN, { code: WAITLIST_ERROR_CODES.FORBIDDEN, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.FORBIDDEN] }); } } else { if (user.role !== "admin") { throw ctx.error(HTTP_STATUS_CODES.FORBIDDEN, { code: WAITLIST_ERROR_CODES.FORBIDDEN, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.FORBIDDEN] }); } } const { id } = ctx.body; const waitlistEntry = await ctx.context.adapter.findOne({ model, where: [ { field: "id", operator: "eq", value: id } ] }); if (!waitlistEntry) { throw ctx.error(HTTP_STATUS_CODES.NOT_FOUND, { code: WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND] }); } await ctx.context.adapter.update({ model, where: [ { field: "id", operator: "eq", value: id } ], update: { status: WAITLIST_STATUS.APPROVED, processedAt: /* @__PURE__ */ new Date(), processedBy: user.id } }); return ctx.json( { message: "Waitlist entry approved" }, { status: HTTP_STATUS_CODES.OK, statusText: HTTP_STATUS_CODE_MESSAGES[HTTP_STATUS_CODES.OK] } ); } ), rejectRequest: createAuthEndpoint( "/waitlist/request/reject", { method: "POST", body: z.object({ id: z.string() }), use: [sessionMiddleware], metadata: { openapi: { responses: { 200: { description: "Waitlist entry rejected" }, 401: { description: "You are not authorized to perform this action" }, 403: { description: "Not enough permissions to perform this action" }, 404: { description: "Waitlist entry not found" } } } } }, async (ctx) => { const { user } = ctx.context.session; if (!user) { throw ctx.error(HTTP_STATUS_CODES.UNAUTHORIZED, { code: WAITLIST_ERROR_CODES.UNAUTHORIZED, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.UNAUTHORIZED] }); } if (opts.canManageWaitlist) { const hasAccess = await opts.canManageWaitlist(user); if (!hasAccess) { throw ctx.error(HTTP_STATUS_CODES.FORBIDDEN, { code: WAITLIST_ERROR_CODES.FORBIDDEN, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.FORBIDDEN] }); } } else { if (user.role !== "admin") { throw ctx.error(HTTP_STATUS_CODES.FORBIDDEN, { code: WAITLIST_ERROR_CODES.FORBIDDEN, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.FORBIDDEN] }); } } const { id } = ctx.body; const waitlistEntry = await ctx.context.adapter.findOne({ model, where: [ { field: "id", operator: "eq", value: id } ] }); if (!waitlistEntry) { throw ctx.error(HTTP_STATUS_CODES.NOT_FOUND, { code: WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND, message: WAITLIST_ERROR_MESSAGES[WAITLIST_ERROR_CODES.WAITLIST_ENTRY_NOT_FOUND] }); } await ctx.context.adapter.update({ model, where: [ { field: "id", operator: "eq", value: id } ], update: { status: WAITLIST_STATUS.REJECTED, processedAt: /* @__PURE__ */ new Date(), processedBy: user.id } }); return ctx.json( { message: "Waitlist entry rejected" }, { status: HTTP_STATUS_CODES.OK, statusText: HTTP_STATUS_CODE_MESSAGES[HTTP_STATUS_CODES.OK] } ); } ) } }; }; function convertAdditionalFieldsToZodSchema(additionalFields) { const additionalFieldsZodSchema = {}; for (const [key, value] of Object.entries(additionalFields)) { let res; if (value.type === "string") { res = z.string(); } else if (value.type === "number") { res = z.number(); } else if (value.type === "boolean") { res = z.boolean(); } else if (value.type === "date") { res = z.date(); } else if (value.type === "string[]") { res = z.array(z.string()); } else { res = z.array(z.number()); } if (!value.required) { res = res.optional(); } additionalFieldsZodSchema[key] = res; } return z.object(additionalFieldsZodSchema); } export { HTTP_STATUS_CODES, HTTP_STATUS_CODE_MESSAGES, WAITLIST_ERROR_CODES, WAITLIST_ERROR_MESSAGES, WAITLIST_MODEL_NAME, WAITLIST_STATUS, schema, waitlist, waitlistClient }; //# sourceMappingURL=index.mjs.map