@ordinatio/scheduling
v0.5.1
Published
Scheduling engine for appointment-based businesses. API-first, no iframes.
Readme
@ordinatio/scheduling
A booking engine. It finds open times, books one, and refuses a second booking for the same time. It keeps its data in your own Postgres database.
Status: version 0.3.0, on npm. A runnable starter that installs it from a packed tarball is in
examples/scheduling-starter in this repository.
Words used here
- Calendar: one person or room that can be booked.
- Appointment type: a kind of booking, such as "Consultation, 30 minutes".
- Weekly hours: the hours a calendar is open each week.
- Slot: one open time that can be booked.
- Adapter: a small piece of code you supply for anything outside the database: payment, email, text messages, calendar sync, the clock, sign-in.
What you need
- Node 22 or newer.
- A Postgres database with nothing in it. Write down its address. It looks like
postgresql://user:password@localhost:5432/your_empty_database. - npm. (pnpm works too.)
- Peer dependency:
@prisma/client7.3 or newer. This README was tested with 7.6.0. The quick start also uses@prisma/adapter-pgto connect to Postgres.
Quick start
You will build a small project that books one appointment and then shows a second booking for the same time being refused. It takes about five minutes.
1. Make a project
Make an empty folder and open a terminal in it. Then run:
npm init -y
npm pkg set type=module
npm install @ordinatio/scheduling @prisma/[email protected] @prisma/[email protected]
npm install --save-dev [email protected] tsx2. Build the tables
Put your own database address in the first line. The package brings its own table definitions and one migration. These two commands create the tables in your empty database.
export DATABASE_URL="postgresql://user:password@localhost:5432/your_empty_database"
npx prisma generate --config node_modules/@ordinatio/scheduling/prisma.config.ts
npx prisma migrate deploy --config node_modules/@ordinatio/scheduling/prisma.config.ts3. Write the script
Save this as book.ts in the same folder. Every table belongs to an organization, so the script first adds
one row for it. That row is the only thing your app must provide.
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import {
createCalendar,
createAppointmentType,
setWeeklyAvailability,
getAvailableSlots,
bookAppointment,
} from '@ordinatio/scheduling';
const connectionString = process.env.DATABASE_URL?.trim();
if (!connectionString) {
console.error('DATABASE_URL is not set.');
process.exit(2);
}
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
// The next weekday at least two days away, at noon UTC, so it is the same day in the Americas.
function nextWeekday(): Date {
const day = new Date();
day.setUTCHours(12, 0, 0, 0);
day.setUTCDate(day.getUTCDate() + 2);
while ([0, 6].includes(day.getUTCDay())) day.setUTCDate(day.getUTCDate() + 1);
return day;
}
async function main(): Promise<void> {
const timezone = 'America/New_York';
const suffix = Date.now().toString(36);
const organization = await prisma.organization.upsert({
where: { id: 'demo-org' },
update: {},
create: { id: 'demo-org', name: 'Demo Studio' },
});
const calendar = await createCalendar(prisma, {
organizationId: organization.id,
name: 'Front desk',
slug: `front-desk-${suffix}`,
type: 'STAFF',
timezone,
});
const appointmentType = await createAppointmentType(prisma, {
organizationId: organization.id,
name: 'Consultation',
slug: `consultation-${suffix}`,
duration: 30,
calendarIds: [calendar.id],
});
await setWeeklyAvailability(
prisma,
calendar.id,
[1, 2, 3, 4, 5].map((dayOfWeek) => ({ dayOfWeek, startTime: '09:00', endTime: '17:00', minNotice: 0 })),
);
const { slots } = await getAvailableSlots(prisma, {
calendarId: calendar.id,
date: nextWeekday(),
duration: appointmentType.duration,
timezone,
});
if (slots.length === 0) throw new Error('No open slots were found.');
const slot = slots[0];
console.log(`Open slots on the demo day: ${slots.length}. Booking the first, at ${slot.start.toISOString()}.`);
const booking = await bookAppointment(prisma, {
calendarId: calendar.id,
typeId: appointmentType.id,
startTime: slot.start,
timezone,
clientName: 'Jane Smith',
clientEmail: '[email protected]',
});
console.log(`Booked: ${booking.id}`);
console.log(` ${booking.clientName} <${booking.clientEmail}>`);
console.log(` ${booking.typeName} with ${booking.calendarName}`);
console.log(` ${booking.startTime.toISOString()} to ${booking.endTime.toISOString()} (${booking.status})`);
try {
await bookAppointment(prisma, {
calendarId: calendar.id,
typeId: appointmentType.id,
startTime: slot.start,
timezone,
clientName: 'John Doe',
clientEmail: '[email protected]',
});
} catch (error) {
const code = (error as { code?: string }).code;
if (code !== 'SCHED_301') throw error;
console.log(`Second booking for the same slot was refused: ${code} (slot not available).`);
console.log('Demo complete.');
return;
}
throw new Error('The second booking for the same slot was accepted. Double booking must be refused.');
}
main()
.catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
})
.finally(() => prisma.$disconnect());4. Run it
npx tsx book.tsIt prints this. The times and the booking id will differ.
Open slots on the demo day: <count>. Booking the first, at <time>.
Booked: <id>
Jane Smith <[email protected]>
Consultation with Front desk
<start> to <end> (CONFIRMED)
Second booking for the same slot was refused: SCHED_301 (slot not available).
Demo complete.The second booking is refused by the database, not only by the script. The package's migration creates a unique index on live bookings, so two people who book the same slot at the same moment cannot both win.
Check that this README still works
From this package's folder, run bash scripts/check-readme.sh. It follows the steps above in an empty folder
against a scratch Postgres database and compares the output with the block above. It needs a local Postgres
that your user can create databases in.
Use it in an app you already have
The tables are defined in node_modules/@ordinatio/scheduling/prisma/schema.prisma. Add those models to your
own Prisma schema, or keep them in their own database. Bookings need an Organization row. If your app
already has an Organization table, merge the two by hand and check the migration before you run it.
Adapters
The engine does not call any vendor. Everything outside the database goes through small adapters you supply, so the same engine runs in tests, in an example app, and in production.
Not every adapter is wired into the engine yet:
- Wired today (through
adaptersToCallbacks):notificationsandpayments. - Interface and in-memory version only, not yet used by the engine:
clock,calendarSync,auth. The engine still reads the system time, and calendar sync and sign-in still live in the host app. A customclockdoes not change the engine's idea of "now" yet.
| Adapter | Job | Promise the contract check enforces |
|---|---|---|
| clock | The current time | now() returns a valid Date and does not go backwards across six consecutive calls |
| notifications | Send an email or text | Never reports SENT for an empty or blank recipient |
| payments | Charge, verify, refund | A zero charge fails; an unknown payment is not paid; verify returns the metadata, and the charged amount when the adapter can convert the currency; a fully refunded payment is not paid; a partial refund needs an idempotencyKey and works up to what is left, never beyond; a repeated refund key refunds once; a repeat refund says ALREADY_REFUNDED; a repeated charge idempotencyKey charges once |
| calendarSync | Add, move, remove a calendar event | Repeat upserts do not throw and keep one external id when ids are returned; removing a missing event does not throw |
| auth | Turn request headers into { userId, organizationId, roles } | Returns null, never throws, for missing or unknown credentials |
The checks cannot see inside your calendar provider, so "one event per appointment" is yours to test beyond what the external id shows.
import { createInMemoryAdapters, adaptersToCallbacks, bookAppointment } from '@ordinatio/scheduling';
const { adapters, records } = createInMemoryAdapters();
await bookAppointment(db, input, adaptersToCallbacks(adapters));
console.log(records.sent); // the confirmation that would have been sentcreateInMemoryAdapters() never touches the network. It records every message, charge, refund and
calendar event, and gives a clock you can move (clock.set(date), clock.advance(ms)). The clock
starts at a fixed date (2026-01-01 UTC), not the real time, unless you pass now.
Check your own adapter
Each adapter has a contract check that returns a list of plain-language failures. An empty list means the adapter keeps the promises above.
import { checkPaymentContract } from '@ordinatio/scheduling';
// A payment the client confirms in the browser (charge returns REQUIRES_ACTION) needs a way to confirm it
// here, for example the provider's test card. Without completePayment the check fails instead of passing.
const failures = await checkPaymentContract(myStripeAdapter, {
completePayment: async (charge) => confirmWithTestCard(charge.paymentId!),
});
if (failures.length > 0) throw new Error(failures.join('\n'));Available: checkClockContract, checkNotificationContract, checkPaymentContract,
checkCalendarSyncContract, checkAuthContract. Run them against a sandbox account, not a live one:
the payment check makes a real charge and refund (use the provider's test mode), the notification check sends a real message to
[email protected], and the calendar check creates and removes a real event. Pass valid
credentials as the second argument to checkAuthContract, or it cannot check that valid sign-ins work.
Webhooks
An organization can register web addresses that are told when something happens to a booking. The events are
appointment.booked, appointment.rescheduled, appointment.cancelled, appointment.no_show,
reminder.sent and waitlist.offered. Run prisma migrate deploy after updating: webhooks need the WebhookEndpoint,
WebhookDelivery and WebhookAttempt tables.
import {
createWebhookEndpoint, enqueueAppointmentWebhook, deliverDueWebhooks,
} from '@ordinatio/scheduling';
// 1. Add an address. The secret is returned once; store it where the receiver can read it.
const { endpoint, secret } = await createWebhookEndpoint(db, {
organizationId, url: 'https://example.com/hooks/scheduling', eventTypes: ['appointment.booked', 'appointment.cancelled'],
});
// 2. When something happens, queue it. This never throws; the booking is already saved.
await enqueueAppointmentWebhook(db, { organizationId, type: 'appointment.booked', appointmentId });
// 3. On a timer, send what is due. Two callers at once never send the same delivery twice.
setInterval(() => {
deliverDueWebhooks(db).catch((error) => console.error('Webhook delivery failed:', error));
}, 10_000);A delivery that fails is tried again after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours. After the sixth
failure it is DEAD_LETTER; retryWebhookDelivery puts it back. Any 2xx answer counts as delivered. A redirect
counts as a failure and is not followed. Every try is kept in WebhookAttempt.
On the receiving side, check the signature on the raw body before trusting it:
import { verifyWebhookSignature } from '@ordinatio/scheduling';
const ok = verifyWebhookSignature({
secret, // the whsec_... value from step 1
header: String(request.headers['x-scheduling-signature'] ?? ''), // t=<seconds>,v1=<hex>
body: rawBody, // the exact text received, not re-serialized JSON
});The header carries a timestamp and the check refuses anything more than 5 minutes old, so a captured delivery
cannot be replayed later. The same event can arrive more than once (a retry after a timeout the receiver did
answer), so act on id once. Each delivery also carries x-scheduling-event and x-scheduling-delivery.
Safety. Addresses must be https and public; localhost, private networks and cloud metadata addresses are
refused when the endpoint is added, and again at the moment of connecting. Pass allowInsecureTargets: true to
createWebhookEndpoint and deliverDueWebhooks only in local development. The secret is stored readable,
because signing needs it; protect database backups as you would any key. To rotate a secret, add a new endpoint
and remove the old one.
To also queue reminder.sent, call processPendingNotifications(db, sender, { webhooks: true }).
Waitlist
A client can wait for a slot or day that is full. When a booking is cancelled or moved, the slot it held is offered to
the person who has waited longest and whose appointment type fits it. Run prisma migrate deploy after updating:
the waitlist needs the WaitlistEntry table.
import { joinWaitlist, offerFreedSlot, expireWaitlistOffers, beginWaitlistAccept, completeWaitlistAccept, releaseWaitlistAccept } from '@ordinatio/scheduling';
// 1. A client joins a full day (or one full slot). Joining is refused while the day still has room: they should book.
const { token } = await joinWaitlist(db, { organizationId, typeId, day: '2026-11-03', clientName: 'Jane', clientEmail: '[email protected]' });
// 2. A booking was cancelled: offer its slot to the first person in line. Returns null when nobody fits.
const offer = await offerFreedSlot(db, { organizationId, calendarId, startTime: cancelledStart });
if (offer) await sendEmail(offer.entry.clientEmail, `https://example.com/offer?token=${offer.token}`);
// 3. On a timer: end offers whose time ran out, and offer each freed slot to the next person.
const { lapsed } = await expireWaitlistOffers(db);
for (const slot of lapsed) await offerFreedSlot(db, slot);When the client opens their link, take the slot in three steps so the booking runs through your normal booking path:
beginWaitlistAccept(db, token) claims the offer (two clicks at once: one wins), you book, then
completeWaitlistAccept(db, entryId, appointmentId). If the booking did not happen, call
releaseWaitlistAccept(db, entryId, outcome): 'KEEP_OFFER' for a temporary failure (the offer stays open),
'BACK_IN_LINE' when the slot was taken (the client keeps their place), or 'DROP' when a booking rule refuses them
(they come off the list). 'DROP' returns the slot.
Pass freed slots on. Three calls return a FreedSlot (or null) that you must hand to offerFreedSlot, or the
next person never hears about it: leaveWaitlist and removeWaitlistEntry (when the entry held an offer) and
releaseWaitlistAccept(..., 'DROP'). expireWaitlistOffers returns its lapsed list for the same reason; it also settles
an accept that started more than five minutes ago and never finished (booked if the booking was saved, otherwise back in
line with its slot added to lapsed). If an accept looks like it failed, call settleBookedAccept(db, entryId) before releasing it: a booking can be saved and a later step fail, and it marks the entry booked and returns the booking when the client already holds the slot.
What an offer is. First dibs, not a hold: until the client accepts, the slot is still open to anyone who books it,
and if someone does, accepting fails and the client keeps their place. One live offer holds a slot at a time (a database
rule), and one person is never offered two slots at once. A waitlist is not available for a type that needs payment or has required intake questions (an accepted offer books with
no answers); joinWaitlist refuses both. Joining checks no one owns the email address, so offer messages can reach a person
who did not ask for them; put your own check (or a CAPTCHA) in front of it for a public page.
token is the client's own handle (it opens the offer and leaves the list); send it only to them.
Routing forms
A routing form asks a client a few questions and sends them to the right appointment type or calendar ("What do you need?
Suit, shirt, repair"). Run prisma migrate deploy after updating: routing forms need the RoutingForm table.
import { createRoutingForm, routeClient } from '@ordinatio/scheduling';
// An admin saves a form. Nothing is stored unless the whole form is valid.
await createRoutingForm(db, organizationId, {
name: 'Fitting finder',
slug: 'fitting-finder',
questions: [
{ id: 'service', label: 'What do you need?', type: 'select', required: true, options: ['Suit', 'Shirt', 'Repair'] },
{ id: 'budget', label: 'Budget', type: 'number' },
],
rules: [
// Rules run in order; the first whose conditions hold wins.
{ id: 'big-suit', match: 'all',
conditions: [{ questionId: 'service', operator: 'equals', value: 'Suit' }, { questionId: 'budget', operator: 'gte', value: 2000 }],
destination: { typeId: premiumTypeId, calendarId: seniorCalendarId } },
{ id: 'suit', conditions: [{ questionId: 'service', operator: 'equals', value: 'Suit' }], destination: { typeId: suitTypeId } },
],
// Required: what happens when no rule matches.
fallback: { kind: 'MESSAGE', message: 'Please call the shop and we will find a time.' },
});
// A client's answers decide where they go.
const outcome = await routeClient(db, { organizationId, slug: 'fitting-finder', answers: { service: 'Suit', budget: 2500 } });
// { destination: { typeId, typeSlug, typeName, calendarId, calendarSlug, calendarName }, message: null, matchedRuleId: 'big-suit', usedFallback: false }Question types are select, radio (one of the options), checkbox (a list of options), text and number.
Operators depend on the type: choice questions allow equals, not_equals, in; checkbox allows contains,
not_contains; text allows equals, not_equals, contains (ignoring case, whereas equals is exact), not_contains; number allows equals,
not_equals, gt, gte, lt, lte; every type allows is_answered and is_empty. A rule's match is all (default) or
any. A question the client left unanswered satisfies only is_empty, so "not equal to X" does not match someone who skipped it.
Checked when saved. createRoutingForm and updateRoutingForm refuse a form that has a rule naming a question that does
not exist, an operator that does not fit the question, a value that is not one of its options, a form with no question, no
rule or no fallback, or a destination that is not live in the organization (the type or calendar is missing, switched off, or
the type is not offered on that calendar). They throw SCHED_1200 with every problem, each with its place such as
rules[2].conditions[0].value, in error.context.errors. An update checks the whole merged form again, destinations
included, when it changes the questions, rules or fallback; a change that leaves those alone (a rename, switching the form
off) is always allowed, even if a destination has been switched off since. A slug is unique within the organization and also
may not be used by another organization's form in the same database, so a public link by slug alone is never ambiguous (a
host with a database per organization names the organization instead).
Checked when used. Destinations are looked up again at the moment a client is routed: a rule whose type or calendar was
switched off since saving is skipped, and the next matching rule (or the fallback) is used. If the fallback destination is gone
too, the client is told to get in touch. getPublicRoutingForm returns the questions only; the rules stay on the server.
testRoutingForm tries sample answers, even on a form that is switched off, and lists every rule that matched.
Limits. 20 questions, 50 rules, 10 conditions per rule, 50 options per question, 50 forms per organization. Answers are not stored: routing a client leaves no record.
Organization brand
Each organization can have a brand: a name, logo, colours, font and footer for the booking page and its messages, and an identity
its messages are sent as. Run prisma migrate deploy after updating: it needs the OrganizationBrand table.
import { saveBrand, getBrand, getPublicBrand } from '@ordinatio/scheduling';
await saveBrand(db, organizationId, {
displayName: 'Acme Fittings',
logoUrl: 'https://cdn.acme.example/logo.png',
primaryColor: '#112233',
footerText: 'Acme, 1 Main St',
replyToEmail: '[email protected]',
fromName: 'Acme Bookings',
fromEmail: '[email protected]',
}, { isSendingDomainVerified: async (domain) => myMailProvider.isVerified(domain) });saveBrand replaces the whole brand and refuses (SCHED_1400) one with any problem, listing every problem with its field.
Colours must be hex (#1a2b3c), the logo an https address, font names plain words, and addresses single plain addresses.
A sending address (fromEmail) is accepted only for a domain isSendingDomainVerified confirms. Without that function no
organization can set one, so an organization can never send as a domain it does not control; replyToEmail needs no check.
The package does not verify domains itself: confirming that your mail provider will send for the domain (SPF, DKIM) is the host's job.
On the booking page. getPublicBrand returns what a visitor may see (never an email address). The API package serves it at
GET /brand and the booking widget applies it.
In messages. Pass { brand: true } to scheduleNotifications, queueImmediateNotification or queueStaffNotification and a
template can use {{brandName}}, {{brandLogoUrl}}, {{brandColor}} and {{brandFooter}} (empty text when the organization has
no brand; HTML-escaped in email bodies). Pass brand: true to processPendingNotifications and each message reaches your sender with
context.from set to its organization's identity ({ name, email?, replyTo? }); a notification adapter receives the same as
message.from. Your sender decides what to do with it: use email as the From address (it is on a domain you confirmed),
name as the display name and replyTo as Reply-To, or ignore from and send from your own address.
Without those options nothing changes and the brand table is never read. The brand fields in a message are filled in when it is
queued, so a message queued before the brand was saved or changed keeps the text it was rendered with. The sender identity is read when
the message is sent, so it follows the brand as it is at that moment, and a domain the host stops confirming keeps being used until the
brand is saved again. A sender name may not contain quotes, angle brackets, commas, semicolons, @ or backslashes.
Data export and erasure
Answers a person's request to see, or to be forgotten. Run prisma migrate deploy after updating: it needs the
DataSubjectRequest table. A person is named by an email address (any letter case), a client id, or both; a client id also
covers every address that client booked with in the organization.
import { exportSubject, previewErase, eraseSubject } from '@ordinatio/scheduling';
const file = await exportSubject(db, { organizationId, requestedBy: staff.userId, email: '[email protected]' });
const preview = await previewErase(db, { organizationId, email: '[email protected]' }); // changes nothing
if (preview.canErase) await eraseSubject(db, { organizationId, requestedBy: staff.userId, email: '[email protected]' });Export returns one document with the person's bookings, the messages sent about them, waitlist entries, package purchases,
recurring series, and the queued webhook events that carry their details. The links and tokens that let someone act as the person
are left out: the self-service link and waitlist link are not exported, and the self-service and calendar links inside message
bodies, and the offer link inside a waitlist event, are replaced with [removed].
Erase keeps what the business needs to keep its books and removes the person from it. Bookings, package purchases and recurring
series stay as records with the name "Erased client" and the address [email protected]; the phone, client id, notes, staff notes,
intake answers and cancellation reason are cleared, and every booking gets a new self-service link so old links stop working. Times,
types, calendars, status, payment amounts and ids, and the meeting link, location and outside-calendar event ids stay (a host that
puts a name in a location or meeting link should clear it itself). Messages, waitlist entries and webhook events that mention the person are
deleted. It runs in one transaction: it all happens or none of it does.
Erase is refused (SCHED_1301) while the person has an upcoming pending or confirmed booking, or a waitlist entry that holds or is
taking an offer. Cancel those first; the refusal says how many there are. previewErase also warns when an active package still has
unused sessions, because after erasure it can no longer be matched to the person.
Every export and erase is recorded in DataSubjectRequest with who asked, the counts, and a SHA-256 digest of each identifier
(tied to the organization), never the address or name. A digest can be recomputed by anyone who already knows the address, so it shows
that a request was made for that address; it is not anonymous. If the record cannot be written, an export returns nothing.
Not covered, because the package does not hold it: the host's own client record, events already created in Google or Outlook calendars (they carry the client's name), the payment provider's records, webhook events already delivered to a receiver, database backups, and logs. Queued webhook events are found by the person's email address in the event text, so an event that names someone without their address is not found. A booking made in another organization under the same address is a different business's record and is never touched.
