public
imalexnord
read
Slot
Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.
Languages
Repository composition by tracked source files.
TypeScript
79%
CSS
19%
SQL
2%
HTML
0%
Create file
Wiki Documentation
Clone
https://nobgit.com/user/imalexnord/slot.git
ssh://[email protected]:2222/user/imalexnord/slot.git
Trace
README.md
Trace helps you understand code history line by line. See who changed each line, when it changed, and which commit introduced it.
Author
Date
Commit
Line
Code
1
# Slot
3
A small calendar-first booking app built as one Cloudflare Worker deployment.
5
The visitor flow is intentionally simple:
7
**date → time → duration → details → confirmed booking**
9
When the selected meeting length is configured as paid, Stripe Checkout is inserted before confirmation:
11
**date → time → duration → details → Stripe payment → confirmed booking**
13
Meeting lengths marked free skip Stripe and use the normal confirmation flow.
15
There are no public accounts and no Google login for visitors. Google OAuth is private to the calendar owner.
17
## Architecture
19
- React + Vite frontend
20
- Cloudflare Worker API
21
- Workers Static Assets, deployed with the Worker as one unit
22
- Cloudflare D1 for settings, booking records, OAuth state, and short-lived slot locks
23
- Google Calendar FreeBusy for live availability
24
- Google Calendar Events API for invites
25
- Google Meet generated on the event when enabled
26
- Optional Stripe Checkout payments through the Stripe API. Without Stripe, bookings confirm normally; when payment is enabled, selected meeting durations can still be configured as free
28
No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service, or separately hosted backend.
30
## What v0.2 does
32
- Calendar-first booking page
33
- Reads live busy periods from one or more Google calendars
34
- Configurable weekly hours
35
- Configurable 15/30/45/60/90/120 minute durations
36
- Minimum notice, booking window, before/after buffers
37
- Google Meet, phone, in-person, or decide-later meeting modes
38
- Rechecks Google immediately before booking
39
- Small invisible per-IP booking-attempt limit plus a form honeypot for basic abuse resistance
40
- Uses transactional D1 slot locks to reduce double-booking races
41
- Creates the Google Calendar event and emails the attendee through Google Calendar
42
- Private `/admin` UI authenticated through the configured Google account
43
- Stores the Google refresh token encrypted at rest in D1 using an AES-GCM key kept as a Worker secret
44
- When a selected meeting length requires payment, creates only a private calendar hold until Stripe confirms payment; free meeting lengths confirm normally
45
- Verifies Stripe webhook signatures before recording payment or refund state
46
- Admin rescheduling moves the existing Google Calendar event and sends attendee updates
47
- Admin can suggest a new time without moving the original booking; the attendee gets a secure accept link through the Google Calendar update
48
- Cancelling a paid booking issues a full Stripe refund to the original payment method before Slot finalizes the cancellation
49
- Every confirmed booking gets a public `SLT-...` booking ID shown on the confirmation screen and Google Calendar invitation; visitors can use it to manage, reschedule, or cancel a future booking from the public booking page
50
- Admin Activity dashboard with booking, completion, cancellation, reschedule, payment, revenue, refund, and average-duration metrics plus a chronological lifecycle feed
51
- Admin Attendees dashboard groups booking history by attendee email, showing repeat bookings, completions, cancellations, reschedules, booked minutes, and payment/refund counts when payments are enabled
52
- Admin booking search matches public `SLT-...` booking IDs, attendee names, and email addresses across upcoming and past bookings
54
## First setup
56
Requirements:
58
- Node.js
59
- a Cloudflare account
60
- a Google Cloud project with **Google Calendar API enabled**
61
- a Google OAuth **Web application** client
63
## Google Cloud setup
65
Google has two separate setup areas that both matter:
67
1. Enable the **Google Calendar API** for the project.
68
2. Create an **OAuth client ID and client secret** for this app.
70
Doing only one of these is not enough. If OAuth exists but the Calendar API is not enabled, Slot can sign in but Google Calendar calls will fail with a Calendar API / FreeBusy error.
72
### 1. Enable Google Calendar API
74
1. Open [Google Cloud Console](https://console.cloud.google.com/).
75
2. Select or create the project you want to use for Slot.
76
3. Go to **APIs & Services** -> **Library**.
77
4. Search for **Google Calendar API**.
78
5. Open **Google Calendar API** and click **Enable**.
80
This is separate from OAuth credentials. The OAuth client controls sign-in; the enabled Calendar API controls whether the app may call FreeBusy and create events.
82
### 2. Configure OAuth consent
84
1. In the same Google Cloud project, go to **APIs & Services** -> **OAuth consent screen**.
85
2. Choose the audience that fits your use:
86
- **Internal** if this is only for your Google Workspace organization.
87
- **External** if you use a normal Gmail account or need access outside one Workspace organization.
88
3. Fill in the app name, support email, and developer contact email.
89
4. If the app is in **External / Testing**, add the same Google account you will use as Slot admin under test users.
91
Slot requests Calendar access only for the configured admin account. Visitors never log in with Google.
93
### 3. Create OAuth client ID and secret
95
1. In Google Cloud, go to **APIs & Services** -> **Credentials**.
96
2. Click **Create credentials** -> **OAuth client ID**.
97
3. Choose **Web application**.
98
4. Give it a name, for example `Slot`.
99
5. Add the public origin for your deployed Slot URL:
101
```text
102
Authorized JavaScript origin:
103
https://book.example.com
104
```
106
6. Add the callback URL for the same deployed Slot URL:
108
```text
109
Authorized redirect URI:
110
https://book.example.com/auth/google/callback
111
```
113
7. Click **Create**.
114
8. Copy the generated **Client ID** and **Client secret**.
115
9. Use those two values when `npm run setup` asks for:
117
```text
118
Google OAuth Client ID
119
Google OAuth Client Secret
120
```
122
If you change the public domain later, update the OAuth client redirect URI to match the new domain exactly.
124
Install:
126
```bash
127
npm install
128
npm run setup
129
```
131
`npm run setup` will:
133
1. use your normal interactive Wrangler login
134
2. build the Worker and frontend assets
135
3. provision or reuse the D1 database
136
4. ask for the public URL, admin Google account, Google Client ID and Client Secret
137
5. generate cryptographic application secrets locally
138
6. upload the secrets to the Worker
139
7. apply the database migration
140
8. deploy the Worker and static assets
142
When setup asks for the public URL, enter the domain where this Worker will be reached, for example:
144
```text
145
https://book.example.com
146
```
148
Configure the Google OAuth client with values derived from that same URL:
150
```text
151
Authorized JavaScript origin:
152
https://book.example.com
154
Authorized redirect URI:
155
https://book.example.com/auth/google/callback
156
```
158
After setup finishes, attach your custom domain to the Worker in Cloudflare Workers & Pages if you are not using the generated `workers.dev` URL. Then open:
160
```text
161
https://book.example.com/admin
162
```
164
Sign in with the email entered during setup. The first authorization requests offline Calendar access so the Worker can check availability and create bookings while you are not present.
166
## Stripe payments
168
Stripe itself is optional. If payments are disabled, Slot confirms bookings immediately as before. If payments are enabled, Slot can charge only the meeting lengths you want: selected durations can be marked free while the others require Stripe Checkout before confirmation.
170
This integration uses Stripe-hosted Checkout, so there is no publishable key or card form in the React app. The Stripe secret key stays in the Worker only.
172
1. In Stripe, create or use the account that should receive booking payments.
173
2. Add the Stripe secret key to the Worker:
175
```bash
176
npx wrangler secret put STRIPE_SECRET_KEY
177
```
179
3. Create a Stripe webhook endpoint pointing to:
181
```text
182
https://book.example.com/api/stripe/webhook
183
```
185
Subscribe it to these Checkout events:
187
```text
188
checkout.session.completed
189
checkout.session.async_payment_succeeded
190
checkout.session.async_payment_failed
191
checkout.session.expired
192
refund.created
193
refund.updated
194
refund.failed
195
```
197
4. Copy the webhook signing secret (it starts with `whsec_`) into the Worker:
199
```bash
200
npx wrangler secret put STRIPE_WEBHOOK_SECRET
201
```
203
5. Apply the payment migration and deploy:
205
```bash
206
npm run db:migrate:remote
207
npm run deploy
208
```
210
6. Open `/admin` → **Settings** → **Stripe payments**. Choose the currency, set a **price per minute** in normal currency units, choose the checkout label, then turn on **Enable Stripe payments**. Under **Free durations**, select any meeting lengths that should bypass Stripe and confirm normally.
212
The Stripe settings card also shows **TEST MODE** for `sk_test_...` keys and **LIVE MODE** for `sk_live_...` keys. The secret key itself is never sent to the browser.
214
For example, `50` with `sek` means **SEK 50 per minute**, so a 15-minute booking costs SEK 750. Slot converts that amount to Stripe minor units internally.
216
Upgrading from v0.1 intentionally disables the old fixed-amount payment toggle until you save a per-minute rate. The old fixed amount cannot be safely guessed as a per-minute price.
218
When Checkout starts, Slot creates a private owner-only calendar hold so nobody else can take the time. The visitor receives no invitation and no Meet link yet. After Stripe reports the payment as paid, Slot converts that hold into the real attendee event and sends the invitation. Cancelling or letting Checkout expire releases the hold.
220
Checkout is limited to card-based payment methods because appointment inventory cannot safely remain reserved for delayed bank-payment methods. Payment status is stored on the booking and shown in admin.
222
Cancelling a paid confirmed booking from Slot Admin issues a **full Stripe refund** against the original PaymentIntent. Slot will not silently cancel a paid booking if the currently configured Stripe key cannot access the environment that took the payment. Refund state is stored on the booking and updated by Stripe refund webhooks when those events are enabled. Refunds always go back to the original payment method.
224
#
225
## Booking management
227
Open a confirmed future booking in `/admin` to manage its time:
229
- **Reschedule** moves the existing booking to another currently available start time. The duration and payment stay unchanged. Google Calendar sends the attendee an updated invitation.
230
- **Suggest new time** leaves the original booking untouched and sends a Google Calendar update containing a secure acceptance link. The suggested time is not held, so Slot rechecks availability when the attendee accepts it. If somebody else takes it first, the attendee is asked to contact the organizer instead of causing a double booking.
231
- **Cancel meeting** cancels the Google Calendar event. If the booking was paid through Stripe, Slot first creates a full refund to the original payment method.
233
Suggestion links contain a high-entropy bearer token. Slot stores only a SHA-256 hash of that token in D1, and the link becomes invalid when the suggestion is accepted, superseded, the booking is rescheduled directly, or the booking is cancelled.
235
Paid reschedules keep the original charge because the meeting duration does not change. Slot does not create a second Checkout Session for a pure time change.
237
Visitors can manage their own future confirmed booking from the public booking page. Each confirmation has a high-entropy `SLT-...` booking ID, also included in the Google Calendar invitation. Entering that ID under **Manage a booking** opens the booking and offers **Reschedule** or **Cancel**. Rescheduling keeps the same duration and payment state and sends an updated Google Calendar invitation. Paid cancellations are refunded through Stripe before the calendar event is cancelled. Public management attempts are rate-limited.
239
## Activity
241
Open `/admin` -> **Activity** for an all-time operational snapshot and recent lifecycle feed. The dashboard tracks:
243
- total bookings, upcoming meetings, completed meetings, cancellations, reschedules, paid bookings, and average meeting length
244
- when payments are enabled: open payment holds plus gross Stripe revenue, refunds, and net revenue, kept separate by currency if the configured currency changes over time
245
- recent booking confirmations, Checkout starts, payments, released holds, reschedules, suggested times, accepted suggestions, refunds, and cancellations
247
Activity events are stored separately from the booking rows so repeated reschedules and payment/refund changes are preserved as history. Migration `0005_activity.sql` backfills the lifecycle points that can be safely reconstructed from existing bookings. Activity logging is observational: a feed-write failure never blocks a booking, payment, refund, cancellation, or calendar update.
249
## Attendees and booking search
251
Open `/admin` -> **Attendees** to see repeat-booking history grouped by normalized attendee email. Each attendee shows total bookings, upcoming/completed meetings, cancellations, reschedules, booked minutes, average duration, and (when Stripe payments are enabled) paid/refunded booking counts. Opening an attendee shows their recent booking history and links back into the normal booking detail view.
253
The **Bookings** tab also has server-backed search. Search by the public `SLT-...` booking ID, internal booking UUID, attendee name, or attendee email; results can still be switched between **Upcoming** and **Past**. Search is not limited to the initially loaded booking list.
255
## Google project in Testing mode
257
If the Google OAuth audience is **External / Testing**, add the admin account as a test user. Google may expire testing-mode authorizations, so move the OAuth app to the appropriate production state when you are satisfied with it.
259
## Later deploys
261
```bash
262
npm run deploy
263
```
265
Apply new D1 migrations when a release adds them:
267
```bash
268
npm run db:migrate:remote
269
```
271
## Local development
273
Copy `.dev.vars.example` to `.dev.vars` and fill in development credentials.
275
```bash
276
npm run db:migrate:local
277
npm run dev
278
```
280
For local Google OAuth, add the localhost callback you actually use to the OAuth client.
282
## Important implementation details
284
### Availability
286
Weekly availability is the outer boundary. Google Calendar is the source of truth for what is actually busy inside that boundary.
288
### Booking race protection
290
Before creating a Google event, the Worker reserves all small time segments touched by the booking (including configured buffers) in D1. Those inserts are submitted as one D1 batch. A uniqueness collision causes the reservation to fail instead of allowing two concurrent bookings to win the same time.
292
The Worker then asks Google FreeBusy one final time before creating the event.
294
### Google tokens
296
The Google refresh token is encrypted with AES-GCM before it is persisted in D1. `TOKEN_ENCRYPTION_KEY` stays in Cloudflare Worker secrets and is not stored in the database or source tree.
298
## Current limits
300
This is a focused first version, not a Cal.com clone.
302
- One owner/admin account
303
- Guest self-service management is booking-ID based rather than account based; the booking ID acts as a high-entropy bearer reference
304
- No recurring availability exceptions UI yet (block time in Google Calendar instead)
305
- Admin currently accepts calendar IDs as text rather than fetching a calendar picker
306
- No custom reminder emails; Google Calendar sends the invitation
308
Those are deliberate cuts to keep deployment and operation small.