@ordinatio/scheduling-api 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/LICENSE +21 -0
- package/README.md +369 -2
- package/dist/chunk-MQO3VRMU.mjs +469 -0
- package/dist/index.d.mts +672 -0
- package/dist/index.d.ts +672 -0
- package/dist/index.js +8247 -0
- package/dist/index.mjs +7792 -0
- package/dist/schemas/index.d.mts +518 -0
- package/dist/schemas/index.d.ts +518 -0
- package/dist/schemas/index.js +532 -0
- package/dist/schemas/index.mjs +80 -0
- package/package.json +61 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0 (2026-10-28)
|
|
4
|
+
|
|
5
|
+
- **Organization brand.** Set `host.brand` to turn it on. Public route: `GET /brand` (what a visitor may see, never an email
|
|
6
|
+
address). Staff routes: `GET`, `PUT` and `DELETE /admin/brand`. A brand with any problem is a 400 listing every problem with its
|
|
7
|
+
field. `host.brand.isSendingDomainVerified(domain, organizationId)` decides which domains an organization may send from; without
|
|
8
|
+
it no sending address is accepted. Queued messages carry the brand fields, and the manual follow-up and reminder texts sign with the
|
|
9
|
+
brand name. With `host.brand` unset the routes answer 501 and messages are unchanged.
|
|
10
|
+
- **Calendar-file event ids** now end in `@ordinatio-scheduling` instead of the name of the business the package was first built for.
|
|
11
|
+
Set `host.icalUidDomain()` to keep an id that calendars have already subscribed to.
|
|
12
|
+
- **Data export and erasure.** Set `host.privacy` to turn them on. Staff routes: `POST /admin/privacy/export` (a JSON file of
|
|
13
|
+
everything held about one person), `POST /admin/privacy/erase` (a preview, or the erasure with `"confirm": true`; 409 while the
|
|
14
|
+
person has upcoming bookings or an open waitlist offer) and `GET /admin/privacy/requests` (the log). The person is named in the
|
|
15
|
+
body, not the address. With `host.privacy` unset the routes answer 501.
|
|
16
|
+
- **Routing forms.** Set `host.routingForms` to turn them on. Public routes: `GET /routing-forms?slug=` (the questions, never
|
|
17
|
+
the rules) and `POST /routing-forms/route` (the answers; the answer is where to send the client, or the form's fallback
|
|
18
|
+
message). Staff routes: `GET` and `POST /admin/routing-forms`, `GET`, `PUT` and `DELETE /admin/routing-forms/[id]`, and
|
|
19
|
+
`POST /admin/routing-forms/[id]/test` (try sample answers, even on a form that is switched off). A form with any problem is
|
|
20
|
+
refused with a 400 that lists every problem with its place. With `host.routingForms` unset the routes answer 501.
|
|
21
|
+
- **Waitlist.** Set `host.waitlist` to turn it on. Public routes: `POST /waitlist` (join a full slot or day; the answer
|
|
22
|
+
holds the client's own `token`), `DELETE /waitlist?token=` (leave), `GET /waitlist/offer?token=` (what was offered),
|
|
23
|
+
`POST /waitlist/offer/accept` (take it; this books through the normal booking route). Staff routes: `GET /admin/waitlist`,
|
|
24
|
+
`DELETE /admin/waitlist/[id]`. Worker route: `POST /internal/waitlist/sweep`. A cancelled, rejected or moved booking
|
|
25
|
+
offers its slot to the first person in line. `host.waitlist.notify` sends the client their link; the
|
|
26
|
+
`waitlist.offered` webhook announces it too. With `host.waitlist` unset the routes answer 501.
|
|
27
|
+
- A client who leaves, or staff who remove someone, while they hold an offer passes the slot to the next person. A failed
|
|
28
|
+
accept leaves the offer open (busy or server error), keeps the client's place (slot taken, 409), or ends it and re-offers
|
|
29
|
+
the slot (a booking rule refuses them). The sweep also settles an accept that never finished.
|
|
30
|
+
- Needs the engine release with the waitlist table.
|
|
31
|
+
|
|
32
|
+
## 0.1.0
|
|
33
|
+
|
|
34
|
+
The first release.
|
|
35
|
+
|
|
36
|
+
- **Outbound webhooks.** Set `host.webhooks` to turn them on. New staff routes: `GET` and `POST /admin/webhooks`,
|
|
37
|
+
`DELETE /admin/webhooks/[id]`, `POST /admin/webhooks/[id]/test`, `GET /admin/webhooks/[id]/deliveries`,
|
|
38
|
+
`POST /admin/webhook-deliveries/[id]/retry`. The signing secret is in the answer to the `POST` and nowhere
|
|
39
|
+
else. New worker route `POST /internal/webhooks/deliver`. Booked, rescheduled, cancelled and no-show events are
|
|
40
|
+
queued from the events the routes already announce. With `host.webhooks` unset, the staff routes answer 501 and
|
|
41
|
+
nothing is queued.
|
|
42
|
+
- Needs `@ordinatio/scheduling` 0.3.0 or newer, which has the webhook tables.
|
|
43
|
+
- **The routes** for booking, managing and running a scheduling system, each a function from a `Request` to a
|
|
44
|
+
`Response`: the public booking routes, the staff routes for calendars, appointment types, availability,
|
|
45
|
+
policies, coupons, packages, group sessions, repeat series and appointment actions, and the calendar sign-in
|
|
46
|
+
routes for Google and Outlook.
|
|
47
|
+
- **`createSchedulingApi({ host })`** serves them all from one `handle(request)`, or one route at a time with
|
|
48
|
+
`handler(method, path)` for a host that routes by file.
|
|
49
|
+
- **The host.** One object supplies the database, who is signed in, the payment provider, the calendar sync,
|
|
50
|
+
the cache and the rate limit. Everything but the database has a safe default.
|
|
51
|
+
- **Sign-in helpers.** `createBearerStaffResolver` signs a request in from its bearer token;
|
|
52
|
+
`createStaffContext` builds the signed-in person for a host that has no group rules.
|
|
53
|
+
- **`createNodeListener`** serves the API from `node:http`: the caller's address comes from the connection, a
|
|
54
|
+
`Host` or `x-forwarded-for` header the caller wrote is ignored, bodies over 1 MB are refused, every `Set-Cookie`
|
|
55
|
+
header is kept, and a thrown error answers 500 instead of stopping the server.
|
|
56
|
+
- **Request schemas** for browser code at `@ordinatio/scheduling-api/schemas`.
|
|
57
|
+
|
|
58
|
+
Needs `@ordinatio/scheduling` 0.2.0 or newer and its database tables.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ordinatio
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,370 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ordinatio/scheduling-api
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The web routes for [`@ordinatio/scheduling`](https://www.npmjs.com/package/@ordinatio/scheduling). Each route is
|
|
4
|
+
a function from a standard `Request` to a standard `Response`, so it runs in Node, Next.js, Hono, Bun and
|
|
5
|
+
anything else that speaks the Fetch API. You supply the database, who is signed in, and anything outside the
|
|
6
|
+
database (payment, calendar sync). Everything else has a safe default.
|
|
7
|
+
|
|
8
|
+
**Status:** version 0.1.0. It needs `@ordinatio/scheduling` 0.3.0 or newer.
|
|
9
|
+
|
|
10
|
+
## Words used here
|
|
11
|
+
|
|
12
|
+
- **Host:** your program. It owns the server, the database and the sign-in. This package owns the routes.
|
|
13
|
+
- **Public routes:** what a client's browser calls: open times, booking, manage a booking.
|
|
14
|
+
- **Staff routes:** what your team calls: calendars, appointment types, hours, coupons, reports. They need a
|
|
15
|
+
signed-in person.
|
|
16
|
+
- **Adapter:** a small piece of code you supply for payment, calendar sync or sign-in.
|
|
17
|
+
|
|
18
|
+
## What you need
|
|
19
|
+
|
|
20
|
+
- Node 22 or newer.
|
|
21
|
+
- A Postgres database with nothing in it, and its address. It looks like
|
|
22
|
+
`postgresql://user:password@localhost:5432/your_empty_database`.
|
|
23
|
+
- npm. (pnpm works too.)
|
|
24
|
+
- A shell that understands `export`, `&` and `$(...)`: bash or zsh on macOS or Linux, or WSL on Windows.
|
|
25
|
+
|
|
26
|
+
## Quick start
|
|
27
|
+
|
|
28
|
+
You will build a small server, add one calendar, book a slot over HTTP, see a second booking for the same slot
|
|
29
|
+
refused, and see that staff routes need a token. It takes about five minutes.
|
|
30
|
+
|
|
31
|
+
### 1. Make a project
|
|
32
|
+
|
|
33
|
+
Make an empty folder and open a terminal in it. Then run:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm init -y
|
|
37
|
+
npm pkg set type=module
|
|
38
|
+
npm install @ordinatio/scheduling @ordinatio/scheduling-api @prisma/client@7.6.0 @prisma/adapter-pg@7.6.0
|
|
39
|
+
npm install --save-dev prisma@7.6.0 tsx
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### 2. Build the tables
|
|
43
|
+
|
|
44
|
+
Put your own database address in the first line. The engine brings its own table definitions and one
|
|
45
|
+
migration.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
export DATABASE_URL="postgresql://user:password@localhost:5432/your_empty_database"
|
|
49
|
+
npx prisma generate --config node_modules/@ordinatio/scheduling/prisma.config.ts
|
|
50
|
+
npx prisma migrate deploy --config node_modules/@ordinatio/scheduling/prisma.config.ts
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### 3. Write the server
|
|
54
|
+
|
|
55
|
+
Save this as `server.ts`. It connects to the database, builds the API from a host object, and hands each
|
|
56
|
+
incoming request to `api.handle`. Staff sign in with `Authorization: Bearer <STAFF_TOKEN>`. A real app looks the
|
|
57
|
+
token up in its own user store; here there is one token.
|
|
58
|
+
|
|
59
|
+
```ts file=server.ts
|
|
60
|
+
import { createHash, timingSafeEqual } from 'node:crypto';
|
|
61
|
+
import { createServer } from 'node:http';
|
|
62
|
+
import { PrismaClient } from '@prisma/client';
|
|
63
|
+
import { PrismaPg } from '@prisma/adapter-pg';
|
|
64
|
+
import { createBearerStaffResolver, createNodeListener, createSchedulingApi } from '@ordinatio/scheduling-api';
|
|
65
|
+
|
|
66
|
+
const connectionString = process.env.DATABASE_URL?.trim();
|
|
67
|
+
const staffToken = process.env.STAFF_TOKEN?.trim();
|
|
68
|
+
if (!connectionString || !staffToken) {
|
|
69
|
+
console.error('Set DATABASE_URL and STAFF_TOKEN.');
|
|
70
|
+
process.exit(2);
|
|
71
|
+
}
|
|
72
|
+
const port = Number(process.env.PORT ?? 3000);
|
|
73
|
+
const publicUrl = `http://localhost:${port}`;
|
|
74
|
+
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
|
|
75
|
+
|
|
76
|
+
// Compares digests so the time taken does not depend on how much of the token matched.
|
|
77
|
+
const digest = (value: string) => createHash('sha256').update(value).digest();
|
|
78
|
+
const isStaffToken = (given: string) => timingSafeEqual(digest(given), digest(staffToken));
|
|
79
|
+
|
|
80
|
+
const api = createSchedulingApi({
|
|
81
|
+
host: {
|
|
82
|
+
publicDb: async () => prisma,
|
|
83
|
+
// Links in booking messages are built from this address.
|
|
84
|
+
appUrl: () => publicUrl,
|
|
85
|
+
staff: createBearerStaffResolver({
|
|
86
|
+
db: () => prisma,
|
|
87
|
+
authenticate: async (token) =>
|
|
88
|
+
isStaffToken(token) ? { userId: 'owner', organizationId: 'demo-org', role: 'OWNER' } : null,
|
|
89
|
+
}),
|
|
90
|
+
// This server speaks plain http on your machine. Leave this out when you serve https.
|
|
91
|
+
secureCookies: () => false,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
createServer(createNodeListener(api, { publicUrl })).listen(port, () =>
|
|
96
|
+
console.log(`Scheduling API on ${publicUrl}/api/scheduling`),
|
|
97
|
+
);
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
`createNodeListener` turns each Node request into a standard `Request`. It takes the caller's address from the
|
|
101
|
+
connection, ignores a `Host` header or `x-forwarded-for` the caller wrote, refuses bodies over 1 MB, keeps every
|
|
102
|
+
`Set-Cookie` header, and answers 500 instead of crashing when something throws. The API limits requests by
|
|
103
|
+
caller address, so keep it: a hand-written adapter that copies the headers lets a caller dodge the limits.
|
|
104
|
+
Behind proxies, pass `trustedProxies: n`, the number of proxies in front of your server (a load balancer
|
|
105
|
+
counts as one). The caller's address is then the nth entry from the right of `x-forwarded-for`, so a proxy that
|
|
106
|
+
appends to the header works as well as one that overwrites it.
|
|
107
|
+
|
|
108
|
+
This one token is for the quick start only. In your own app, look up a random token of 32 bytes or more in your
|
|
109
|
+
user store, and store it hashed.
|
|
110
|
+
|
|
111
|
+
### 4. Add a calendar
|
|
112
|
+
|
|
113
|
+
Save this as `seed.ts`. It adds the organization row, one calendar open on weekdays, one 30-minute appointment
|
|
114
|
+
type, and prints the numbers the next step needs.
|
|
115
|
+
|
|
116
|
+
```ts file=seed.ts
|
|
117
|
+
import { PrismaClient } from '@prisma/client';
|
|
118
|
+
import { PrismaPg } from '@prisma/adapter-pg';
|
|
119
|
+
import {
|
|
120
|
+
createAppointmentType,
|
|
121
|
+
createCalendar,
|
|
122
|
+
getAvailableSlots,
|
|
123
|
+
setWeeklyAvailability,
|
|
124
|
+
} from '@ordinatio/scheduling';
|
|
125
|
+
|
|
126
|
+
const connectionString = process.env.DATABASE_URL?.trim();
|
|
127
|
+
if (!connectionString) {
|
|
128
|
+
console.error('DATABASE_URL is not set.');
|
|
129
|
+
process.exit(2);
|
|
130
|
+
}
|
|
131
|
+
const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) });
|
|
132
|
+
const timezone = 'America/New_York';
|
|
133
|
+
const suffix = Date.now().toString(36);
|
|
134
|
+
|
|
135
|
+
const day = new Date();
|
|
136
|
+
day.setUTCHours(12, 0, 0, 0);
|
|
137
|
+
day.setUTCDate(day.getUTCDate() + 2);
|
|
138
|
+
while ([0, 6].includes(day.getUTCDay())) day.setUTCDate(day.getUTCDate() + 1);
|
|
139
|
+
|
|
140
|
+
await prisma.organization.upsert({ where: { id: 'demo-org' }, update: {}, create: { id: 'demo-org', name: 'Demo Studio' } });
|
|
141
|
+
const calendar = await createCalendar(prisma, {
|
|
142
|
+
organizationId: 'demo-org',
|
|
143
|
+
name: 'Front desk',
|
|
144
|
+
slug: `front-desk-${suffix}`,
|
|
145
|
+
type: 'STAFF',
|
|
146
|
+
timezone,
|
|
147
|
+
});
|
|
148
|
+
const appointmentType = await createAppointmentType(prisma, {
|
|
149
|
+
organizationId: 'demo-org',
|
|
150
|
+
name: 'Consultation',
|
|
151
|
+
slug: `consultation-${suffix}`,
|
|
152
|
+
duration: 30,
|
|
153
|
+
calendarIds: [calendar.id],
|
|
154
|
+
});
|
|
155
|
+
await setWeeklyAvailability(
|
|
156
|
+
prisma,
|
|
157
|
+
calendar.id,
|
|
158
|
+
[1, 2, 3, 4, 5].map((dayOfWeek) => ({ dayOfWeek, startTime: '09:00', endTime: '17:00', minNotice: 0 })),
|
|
159
|
+
);
|
|
160
|
+
const { slots } = await getAvailableSlots(prisma, { calendarId: calendar.id, date: day, duration: 30, timezone });
|
|
161
|
+
if (slots.length === 0) throw new Error('No open slots were found for the demo day.');
|
|
162
|
+
console.log(`CALENDAR=${calendar.id}`);
|
|
163
|
+
console.log(`TYPE=${appointmentType.id}`);
|
|
164
|
+
console.log(`DAY=${day.toISOString().slice(0, 10)}`);
|
|
165
|
+
console.log(`START=${slots[0].start.toISOString()}`);
|
|
166
|
+
await prisma.$disconnect();
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### 5. Run it and try it
|
|
170
|
+
|
|
171
|
+
This starts the server, then calls it the way a booking page would. Each `curl` prints one line.
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
(
|
|
175
|
+
export STAFF_TOKEN="a-long-random-text"
|
|
176
|
+
export PORT=3100
|
|
177
|
+
npx tsx seed.ts > seed.out || { cat seed.out; exit 1; }
|
|
178
|
+
set -a; . ./seed.out; set +a
|
|
179
|
+
node --import tsx server.ts > server.log 2>&1 &
|
|
180
|
+
SERVER_PID=$!
|
|
181
|
+
trap 'kill $SERVER_PID' EXIT
|
|
182
|
+
for attempt in $(seq 1 30); do
|
|
183
|
+
kill -0 $SERVER_PID || { echo "The server stopped. Is port $PORT in use? See server.log."; exit 1; }
|
|
184
|
+
curl -s -o /dev/null "http://localhost:$PORT/api/scheduling/widget-init" && break
|
|
185
|
+
sleep 1
|
|
186
|
+
done
|
|
187
|
+
API="http://localhost:$PORT/api/scheduling"
|
|
188
|
+
BODY="{\"calendarId\":\"$CALENDAR\",\"typeId\":\"$TYPE\",\"startTime\":\"$START\",\"timezone\":\"America/New_York\",\"clientName\":\"Jane Smith\",\"clientEmail\":\"jane@example.com\"}"
|
|
189
|
+
curl -s -o /dev/null -w "Open times on the day: HTTP %{http_code}\n" "$API/availability?calendarId=$CALENDAR&typeId=$TYPE&date=$DAY&timezone=America/New_York"
|
|
190
|
+
curl -s -o /dev/null -w "First booking: HTTP %{http_code}\n" -X POST -H "content-type: application/json" -d "$BODY" "$API/appointments"
|
|
191
|
+
curl -s -o /dev/null -w "Same slot again: HTTP %{http_code}\n" -X POST -H "content-type: application/json" -d "$BODY" "$API/appointments"
|
|
192
|
+
curl -s -o /dev/null -w "Calendars without a token: HTTP %{http_code}\n" "$API/admin/calendars"
|
|
193
|
+
curl -s -o /dev/null -w "Calendars with the token: HTTP %{http_code}\n" -H "authorization: Bearer $STAFF_TOKEN" "$API/admin/calendars"
|
|
194
|
+
)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
The round brackets run all of this in its own shell, so the server stops when the block ends and a failure cannot
|
|
198
|
+
close your terminal. The server's own output goes to `server.log`.
|
|
199
|
+
|
|
200
|
+
It prints this:
|
|
201
|
+
|
|
202
|
+
```text expected
|
|
203
|
+
Open times on the day: HTTP 200
|
|
204
|
+
First booking: HTTP 201
|
|
205
|
+
Same slot again: HTTP 409
|
|
206
|
+
Calendars without a token: HTTP 401
|
|
207
|
+
Calendars with the token: HTTP 200
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
The second booking is refused by the database, not only by the route, so two people who book the same slot at
|
|
211
|
+
the same moment cannot both win.
|
|
212
|
+
|
|
213
|
+
### Check that this README still works
|
|
214
|
+
|
|
215
|
+
From this package's folder, run `bash scripts/check-readme.sh`. It follows the steps above in an empty folder
|
|
216
|
+
against a scratch Postgres database and compares the output with the block above. It needs a local Postgres
|
|
217
|
+
that your user can create databases in.
|
|
218
|
+
|
|
219
|
+
## The host object
|
|
220
|
+
|
|
221
|
+
`createSchedulingApi({ host })` takes one object. Only `publicDb` is required.
|
|
222
|
+
|
|
223
|
+
| Member | What it does | Default |
|
|
224
|
+
| --- | --- | --- |
|
|
225
|
+
| `publicDb()` | The database. | required |
|
|
226
|
+
| `appUrl()` | The web address of your server. Booking needs it to build links in messages. | none: booking answers 500 |
|
|
227
|
+
| `staff` | Who is signed in. `createBearerStaffResolver` reads a bearer token; `createStaffContext` builds the person for your own sign-in. | nobody: staff routes answer 401 |
|
|
228
|
+
| `payments` | Charges, verifies and refunds payments. | none: a type that needs payment cannot be booked |
|
|
229
|
+
| `calendarSync` | Keeps Google or Outlook in step. | nothing is synced |
|
|
230
|
+
| `rateLimit` | Counts requests, by caller address. Return a response (normally 429) to stop one. Your server must set the address from the connection (see `createNodeListener`). | in-memory, per process |
|
|
231
|
+
| `secureCookies()` | Whether the short-lived sign-in cookies travel only over https. | yes: on plain http the cookies do not stick |
|
|
232
|
+
| `cache`, `availabilityCache` | Stored answers. | in-memory, per process |
|
|
233
|
+
| `publicBooking`, `organizations`, `tenantDb` | Find the organization and database of a request. | one organization |
|
|
234
|
+
| `events`, `activity` | Tell your screens or activity feed what changed. | nothing |
|
|
235
|
+
| `internalAccess`, `workerCommands` | The background worker's own routes. | closed |
|
|
236
|
+
| `calendarConnectors` | The Google and Outlook sign-in flows. | not configured |
|
|
237
|
+
|
|
238
|
+
Everything else on `SchedulingHost` is documented in its type. If a member you do not set is needed by a route,
|
|
239
|
+
the route says so in its answer instead of guessing.
|
|
240
|
+
|
|
241
|
+
## Webhooks
|
|
242
|
+
|
|
243
|
+
Set `webhooks: {}` on the host to tell other systems about bookings. Staff then have these routes, all for
|
|
244
|
+
admins:
|
|
245
|
+
|
|
246
|
+
| Route | What it does |
|
|
247
|
+
|---|---|
|
|
248
|
+
| `GET /admin/webhooks` | Lists the endpoints and the event names. Never shows a secret. |
|
|
249
|
+
| `POST /admin/webhooks` | Adds one: `{ "url": "https://...", "eventTypes": ["appointment.booked"], "description": "CRM" }`. The answer holds the signing `secret`; it is shown once. |
|
|
250
|
+
| `DELETE /admin/webhooks/[id]` | Removes an endpoint with its deliveries. |
|
|
251
|
+
| `POST /admin/webhooks/[id]/test` | Sends one signed `webhook.test` event now and reports what the receiver answered. |
|
|
252
|
+
| `GET /admin/webhooks/[id]/deliveries` | The delivery log, with every try. `?status=DEAD_LETTER` filters. |
|
|
253
|
+
| `POST /admin/webhook-deliveries/[id]/retry` | Sends a delivery that ran out of tries again. |
|
|
254
|
+
|
|
255
|
+
Events are queued for you. Sending is done by a timer you run: call `deliverDueWebhooks(db)` from
|
|
256
|
+
`@ordinatio/scheduling` every few seconds, or have your worker `POST /internal/webhooks/deliver` (it needs
|
|
257
|
+
`host.internalAccess`). Without `webhooks` on the host the routes answer 501 and nothing is queued. In local
|
|
258
|
+
development, `webhooks: { allowInsecureTargets: true }` lets an endpoint point at `http://localhost`. The engine
|
|
259
|
+
README explains the retry schedule and how a receiver checks the signature. The events are `appointment.booked`,
|
|
260
|
+
`appointment.rescheduled`, `appointment.cancelled`, `appointment.no_show` and `waitlist.offered`; `reminder.sent` is sent when
|
|
261
|
+
the worker that sends reminders passes `webhooks: true` to `processPendingNotifications`.
|
|
262
|
+
|
|
263
|
+
## Waitlist
|
|
264
|
+
|
|
265
|
+
Set `waitlist: {}` on the host to let clients wait for a full slot or day. A cancelled, rejected or moved booking offers
|
|
266
|
+
the slot it held to the person who has waited longest.
|
|
267
|
+
|
|
268
|
+
| Route | Who | What it does |
|
|
269
|
+
|---|---|---|
|
|
270
|
+
| `POST /waitlist` | public | Joins: `{ "typeId": "...", "day": "2026-11-03", "slotStart": "...", "clientName": "...", "clientEmail": "..." }` (`slotStart`, `calendarId` and `orgSlug` are optional). Refused with 409 while the slot or day still has room. The answer holds the client's own `token`. |
|
|
271
|
+
| `DELETE /waitlist?token=` | public | Leaves the list. |
|
|
272
|
+
| `GET /waitlist/offer?token=` | public | What was offered, until when, and the name the client gave. No email or phone. |
|
|
273
|
+
| `POST /waitlist/offer/accept` | public | `{ "token": "..." }` books the offered slot through the normal booking route, so your rules, messages and calendar sync apply. 409 `SLOT_UNAVAILABLE` if someone booked it first; the client keeps their place. |
|
|
274
|
+
| `GET /admin/waitlist` | admin | The people waiting, oldest first. `?status=` filters. |
|
|
275
|
+
| `DELETE /admin/waitlist/[id]` | admin | Takes someone off. |
|
|
276
|
+
| `POST /internal/waitlist/sweep` | worker | Ends offers whose time ran out, settles an accept that never finished (booked, or back in line), and offers each freed slot to the next person. Call it every minute or so; it answers `{ lapsed, released, booked, reoffered }` (offers ended, accepts sent back in line, accepts found already booked, slots offered onward). A person sent back in line from a stuck accept is first in line again, so they may be offered the same slot. It serves the host's public database; a host with a database per organization calls `expireWaitlistOffers` and `offerFreedSlot` from the engine itself for each one. |
|
|
277
|
+
|
|
278
|
+
`host.waitlist.notify(offer)` is where you send the client their link (put `offer.token` in it and open the offer page, which
|
|
279
|
+
calls the two offer routes). The `waitlist.offered` webhook announces the same thing. `host.waitlist.offerMinutes` sets the
|
|
280
|
+
length of an offer (default 30; never past the start of the slot). An offer is first dibs, not a hold. A type that needs payment
|
|
281
|
+
or with required intake questions has no waitlist. A client who leaves, or staff who remove someone, while they hold an offer
|
|
282
|
+
passes the slot to the next person. If accepting fails because the server is busy the offer stays open; if the slot was
|
|
283
|
+
taken the client keeps their place; if a booking rule refuses them they come off the list and the slot is re-offered.
|
|
284
|
+
Joining does not check that the email belongs to the person joining, and the `waitlist.offered` webhook body contains the
|
|
285
|
+
client's private token (visible to admins in the delivery log), so protect the join page and the endpoint accordingly.
|
|
286
|
+
Without `waitlist` on the host the routes answer 501.
|
|
287
|
+
|
|
288
|
+
## Routing forms
|
|
289
|
+
|
|
290
|
+
Set `routingForms: {}` on the host to let admins build short forms that send a client to the right appointment type or
|
|
291
|
+
calendar. The engine README explains question types, operators and how a form is checked.
|
|
292
|
+
|
|
293
|
+
| Route | Who | What it does |
|
|
294
|
+
|---|---|---|
|
|
295
|
+
| `GET /routing-forms?slug=` | public | The form's questions. Never the rules or the fallback. `orgSlug` names the organization when one database holds several. |
|
|
296
|
+
| `POST /routing-forms/route` | public | `{ "slug": "...", "answers": { "service": "Suit" } }` (send `answers: {}` when every question is optional and unanswered) answers with `{ destination, message, matchedRuleId, usedFallback }`. `destination` has the type and calendar ids, slugs and names to build a booking link from; it is null when the form's fallback is a message. 400 with `details` when the answers do not fit. |
|
|
297
|
+
| `GET /admin/routing-forms`, `POST /admin/routing-forms` | admin | List the forms; save a new one. A form with any problem is refused with a 400 whose `details` list every problem with its place (`rules[1].conditions[0].questionId`). |
|
|
298
|
+
| `GET /admin/routing-forms/[id]`, `PUT`, `DELETE` | admin | Read one in full; change it (the whole result is checked again); remove it. |
|
|
299
|
+
| `POST /admin/routing-forms/[id]/test` | admin | `{ "answers": { ... } }` tries sample answers, even on a form that is switched off, and lists every rule that matched. |
|
|
300
|
+
|
|
301
|
+
A slug is unique across the organizations of one database, so a link by slug alone is not ambiguous; a host with a database per
|
|
302
|
+
organization passes `orgSlug`. The public routes answer 404 for an unknown, switched-off or ambiguous form, and the staff routes
|
|
303
|
+
answer 409 for a slug already in use in the organization (`SCHED_1202`) or the 50-form limit (`SCHED_1204`) and 404 for another organization's form. The test route's
|
|
304
|
+
answer adds `allMatchedRuleIds`, every rule the answers satisfy. `matchedRuleId` is the rule id the admin chose, so pick ids that are
|
|
305
|
+
safe to show. Routing does not stop a client from booking something else: it only suggests where to go. Without
|
|
306
|
+
`routingForms` on the host the routes answer 501.
|
|
307
|
+
|
|
308
|
+
## Organization brand
|
|
309
|
+
|
|
310
|
+
Set `brand: {}` on the host to let each organization carry a name, logo, colours, font and footer on its booking page and in its
|
|
311
|
+
messages, and a sender identity. The engine README explains what each field accepts.
|
|
312
|
+
|
|
313
|
+
| Route | Who | What it does |
|
|
314
|
+
|---|---|---|
|
|
315
|
+
| `GET /brand` | public | The brand a visitor may see: name, logo, colours, font, footer. Never an email address. `orgSlug` names the organization when one database holds several; with none named, the brand of the only organization that has one. 404 when there is none. |
|
|
316
|
+
| `GET /admin/brand`, `PUT /admin/brand`, `DELETE /admin/brand` | admin | Read the whole brand including its email settings (`{ brand: null }` when none); replace it; remove it. A brand with any problem is a 400 whose `details` list every problem with its field. |
|
|
317
|
+
|
|
318
|
+
**Sending from the organization's own domain.** `fromEmail` is accepted only if the host says it may send from that domain:
|
|
319
|
+
|
|
320
|
+
```ts
|
|
321
|
+
brand: { isSendingDomainVerified: async (domain, organizationId) => myMailProvider.isVerified(domain) }
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
With no `isSendingDomainVerified`, every `fromEmail` is refused (`replyToEmail` and the rest are still fine). Your message sender then
|
|
325
|
+
decides how to use the identity (see the engine README). Messages queued when a booking is made, moved or cancelled carry the brand
|
|
326
|
+
fields for templates, and the manual follow-up and reminder texts sign with the brand name when a calendar has no staff name.
|
|
327
|
+
|
|
328
|
+
The booking widget loads `GET /brand` by itself and applies it; a colour the page sets in `theme` always wins over the brand's.
|
|
329
|
+
`icalUidDomain()` on the host sets the domain in calendar-file event ids (default `ordinatio-scheduling`); once calendars have subscribed, keep it unchanged.
|
|
330
|
+
Without `brand` on the host the routes answer 501.
|
|
331
|
+
|
|
332
|
+
## Data export and erasure
|
|
333
|
+
|
|
334
|
+
Set `privacy: {}` on the host to let an admin export or erase everything held about one person. The engine README explains what
|
|
335
|
+
each does, what it keeps, and what it does not reach. The person is named in the request body, never in the address, so an email
|
|
336
|
+
does not end up in access logs.
|
|
337
|
+
|
|
338
|
+
| Route | Who | What it does |
|
|
339
|
+
|---|---|---|
|
|
340
|
+
| `POST /admin/privacy/export` | admin | `{ "email": "..." }` and/or `{ "clientId": "..." }`. Answers with the export as a JSON file to save (`content-disposition: attachment`, never cached). |
|
|
341
|
+
| `POST /admin/privacy/erase` | admin | The same body. Without `"confirm": true` it only previews: `{ erased: false, preview: { counts, blockers, canErase, warnings } }`. With it, erases and answers `{ erased: true, counts }`. 409 (`SCHED_1301`) while the person has upcoming bookings or an open waitlist offer, with the numbers in `details.blockers`. |
|
|
342
|
+
| `GET /admin/privacy/requests` | admin | The log of exports and erasures: when, who asked, how many rows. It holds no names or addresses. |
|
|
343
|
+
|
|
344
|
+
A body that names nobody, names an invalid address, or carries other fields is a 400. Both actions are rate limited and written to
|
|
345
|
+
the host's activity feed with counts only. Without `privacy` on the host the routes answer 501.
|
|
346
|
+
|
|
347
|
+
## Serving the routes
|
|
348
|
+
|
|
349
|
+
- **One handler:** `api.handle(request)` serves every route under `/api/scheduling` (change it with
|
|
350
|
+
`basePath`).
|
|
351
|
+
- **One file per route**, in a framework that routes by file such as Next.js:
|
|
352
|
+
`export const POST = api.handler('POST', '/coupons/validate');`
|
|
353
|
+
- **Request shapes for your own browser code:** `import { ... } from '@ordinatio/scheduling-api/schemas'`.
|
|
354
|
+
|
|
355
|
+
## Sign-in
|
|
356
|
+
|
|
357
|
+
Staff routes ask the host who is calling. The two helpers cover the common cases:
|
|
358
|
+
|
|
359
|
+
- `createBearerStaffResolver({ db, authenticate })` reads `Authorization: Bearer <token>`; your
|
|
360
|
+
`authenticate` function turns the token into `{ userId, organizationId, role }` or `null`.
|
|
361
|
+
- `createStaffContext({ userId, organizationId, role, db })` builds the person yourself, for cookies or
|
|
362
|
+
sessions. Owners, admins and managers count as administrators; pass `adminRoles` to change that.
|
|
363
|
+
|
|
364
|
+
Both give the person access to every calendar of the organization. If you have groups that should see only
|
|
365
|
+
some calendars, build your own `StaffContext` and implement `visibleCalendars`, `visibleCalendarIds`,
|
|
366
|
+
`canSeeCalendar` and `canSeeAppointment`.
|
|
367
|
+
|
|
368
|
+
## License
|
|
369
|
+
|
|
370
|
+
MIT
|