@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 +29 -0
- package/LICENSE +21 -0
- package/README.md +283 -2
- package/dist/chunk-IAQBLF5F.mjs +411 -0
- package/dist/index.d.mts +611 -0
- package/dist/index.d.ts +611 -0
- package/dist/index.js +7485 -0
- package/dist/index.mjs +7075 -0
- package/dist/schemas/index.d.mts +464 -0
- package/dist/schemas/index.d.ts +464 -0
- package/dist/schemas/index.js +467 -0
- package/dist/schemas/index.mjs +66 -0
- package/package.json +61 -3
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
|
-
#
|
|
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.
|
|
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
|