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
JavaScript
// 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