@ordinatio/scheduling-api 0.0.0-stage → 0.1.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 ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ The first release.
6
+
7
+ - **Outbound webhooks.** Set `host.webhooks` to turn them on. New staff routes: `GET` and `POST /admin/webhooks`,
8
+ `DELETE /admin/webhooks/[id]`, `POST /admin/webhooks/[id]/test`, `GET /admin/webhooks/[id]/deliveries`,
9
+ `POST /admin/webhook-deliveries/[id]/retry`. The signing secret is in the answer to the `POST` and nowhere
10
+ else. New worker route `POST /internal/webhooks/deliver`. Booked, rescheduled, cancelled and no-show events are
11
+ queued from the events the routes already announce. With `host.webhooks` unset, the staff routes answer 501 and
12
+ nothing is queued.
13
+ - Needs `@ordinatio/scheduling` 0.3.0 or newer, which has the webhook tables.
14
+ - **The routes** for booking, managing and running a scheduling system, each a function from a `Request` to a
15
+ `Response`: the public booking routes, the staff routes for calendars, appointment types, availability,
16
+ policies, coupons, packages, group sessions, repeat series and appointment actions, and the calendar sign-in
17
+ routes for Google and Outlook.
18
+ - **`createSchedulingApi({ host })`** serves them all from one `handle(request)`, or one route at a time with
19
+ `handler(method, path)` for a host that routes by file.
20
+ - **The host.** One object supplies the database, who is signed in, the payment provider, the calendar sync,
21
+ the cache and the rate limit. Everything but the database has a safe default.
22
+ - **Sign-in helpers.** `createBearerStaffResolver` signs a request in from its bearer token;
23
+ `createStaffContext` builds the signed-in person for a host that has no group rules.
24
+ - **`createNodeListener`** serves the API from `node:http`: the caller's address comes from the connection, a
25
+ `Host` or `x-forwarded-for` header the caller wrote is ignored, bodies over 1 MB are refused, every `Set-Cookie`
26
+ header is kept, and a thrown error answers 500 instead of stopping the server.
27
+ - **Request schemas** for browser code at `@ordinatio/scheduling-api/schemas`.
28
+
29
+ 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,284 @@
1
- # Temporary Holding Version
1
+ # @ordinatio/scheduling-api
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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.
260
+
261
+ ## Serving the routes
262
+
263
+ - **One handler:** `api.handle(request)` serves every route under `/api/scheduling` (change it with
264
+ `basePath`).
265
+ - **One file per route**, in a framework that routes by file such as Next.js:
266
+ `export const POST = api.handler('POST', '/coupons/validate');`
267
+ - **Request shapes for your own browser code:** `import { ... } from '@ordinatio/scheduling-api/schemas'`.
268
+
269
+ ## Sign-in
270
+
271
+ Staff routes ask the host who is calling. The two helpers cover the common cases:
272
+
273
+ - `createBearerStaffResolver({ db, authenticate })` reads `Authorization: Bearer <token>`; your
274
+ `authenticate` function turns the token into `{ userId, organizationId, role }` or `null`.
275
+ - `createStaffContext({ userId, organizationId, role, db })` builds the person yourself, for cookies or
276
+ sessions. Owners, admins and managers count as administrators; pass `adminRoles` to change that.
277
+
278
+ Both give the person access to every calendar of the organization. If you have groups that should see only
279
+ some calendars, build your own `StaffContext` and implement `visibleCalendars`, `visibleCalendarIds`,
280
+ `canSeeCalendar` and `canSeeAppointment`.
281
+
282
+ ## License
283
+
284
+ MIT