NobGit
public imalexnord read

Slot

Calendar-first scheduling powered by Cloudflare Workers and Google Calendar.

Languages

Repository composition by tracked source files.

TypeScript
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
README.md
# Slot

A small calendar-first booking app built as one Cloudflare Worker deployment.

The visitor flow is intentionally simple:

**date → time → duration → details → confirmed booking**

When the selected meeting length is configured as paid, Stripe Checkout is inserted before confirmation:

**date → time → duration → details → Stripe payment → confirmed booking**

Meeting lengths marked free skip Stripe and use the normal confirmation flow.

There are no public accounts and no Google login for visitors. Google OAuth is private to the calendar owner.

## Architecture

- React + Vite frontend
- Cloudflare Worker API
- Workers Static Assets, deployed with the Worker as one unit
- Cloudflare D1 for settings, booking records, OAuth state, and short-lived slot locks
- Google Calendar FreeBusy for live availability
- Google Calendar Events API for invites
- Google Meet generated on the event when enabled
- 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

No GitHub Actions, Pages Functions, KV, Microsoft OAuth, external email service, or separately hosted backend.

## What v0.2 does

- Calendar-first booking page
- Reads live busy periods from one or more Google calendars
- Configurable weekly hours
- Configurable 15/30/45/60/90/120 minute durations
- Minimum notice, booking window, before/after buffers
- Google Meet, phone, in-person, or decide-later meeting modes
- Rechecks Google immediately before booking
- Small invisible per-IP booking-attempt limit plus a form honeypot for basic abuse resistance
- Uses transactional D1 slot locks to reduce double-booking races
- Creates the Google Calendar event and emails the attendee through Google Calendar
- Private `/admin` UI authenticated through the configured Google account
- Stores the Google refresh token encrypted at rest in D1 using an AES-GCM key kept as a Worker secret
- When a selected meeting length requires payment, creates only a private calendar hold until Stripe confirms payment; free meeting lengths confirm normally
- Verifies Stripe webhook signatures before recording payment or refund state
- Admin rescheduling moves the existing Google Calendar event and sends attendee updates
- Admin can suggest a new time without moving the original booking; the attendee gets a secure accept link through the Google Calendar update
- Cancelling a paid booking issues a full Stripe refund to the original payment method before Slot finalizes the cancellation
- 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
- Admin Activity dashboard with booking, completion, cancellation, reschedule, payment, revenue, refund, and average-duration metrics plus a chronological lifecycle feed
- 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
- Admin booking search matches public `SLT-...` booking IDs, attendee names, and email addresses across upcoming and past bookings

## First setup

Requirements:

- Node.js
- a Cloudflare account
- a Google Cloud project with **Google Calendar API enabled**
- a Google OAuth **Web application** client

## Google Cloud setup

Google has two separate setup areas that both matter:

1. Enable the **Google Calendar API** for the project.
2. Create an **OAuth client ID and client secret** for this app.

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.

### 1. Enable Google Calendar API

1. Open [Google Cloud Console](https://console.cloud.google.com/).
2. Select or create the project you want to use for Slot.
3. Go to **APIs & Services** -> **Library**.
4. Search for **Google Calendar API**.
5. Open **Google Calendar API** and click **Enable**.

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.

### 2. Configure OAuth consent

1. In the same Google Cloud project, go to **APIs & Services** -> **OAuth consent screen**.
2. Choose the audience that fits your use:
   - **Internal** if this is only for your Google Workspace organization.
   - **External** if you use a normal Gmail account or need access outside one Workspace organization.
3. Fill in the app name, support email, and developer contact email.
4. If the app is in **External / Testing**, add the same Google account you will use as Slot admin under test users.

Slot requests Calendar access only for the configured admin account. Visitors never log in with Google.

### 3. Create OAuth client ID and secret

1. In Google Cloud, go to **APIs & Services** -> **Credentials**.
2. Click **Create credentials** -> **OAuth client ID**.
3. Choose **Web application**.
4. Give it a name, for example `Slot`.
5. Add the public origin for your deployed Slot URL:

```text
Authorized JavaScript origin:
https://book.example.com
```

6. Add the callback URL for the same deployed Slot URL:

```text
Authorized redirect URI:
https://book.example.com/auth/google/callback
```

7. Click **Create**.
8. Copy the generated **Client ID** and **Client secret**.
9. Use those two values when `npm run setup` asks for:

```text
Google OAuth Client ID
Google OAuth Client Secret
```

If you change the public domain later, update the OAuth client redirect URI to match the new domain exactly.

Install:

```bash
npm install
npm run setup
```

`npm run setup` will:

1. use your normal interactive Wrangler login
2. build the Worker and frontend assets
3. provision or reuse the D1 database
4. ask for the public URL, admin Google account, Google Client ID and Client Secret
5. generate cryptographic application secrets locally
6. upload the secrets to the Worker
7. apply the database migration
8. deploy the Worker and static assets

When setup asks for the public URL, enter the domain where this Worker will be reached, for example:

```text
https://book.example.com
```

Configure the Google OAuth client with values derived from that same URL:

```text
Authorized JavaScript origin:
https://book.example.com

Authorized redirect URI:
https://book.example.com/auth/google/callback
```

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:

```text
https://book.example.com/admin
```

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.

## Stripe payments

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.

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.

1. In Stripe, create or use the account that should receive booking payments.
2. Add the Stripe secret key to the Worker:

```bash
npx wrangler secret put STRIPE_SECRET_KEY
```

3. Create a Stripe webhook endpoint pointing to:

```text
https://book.example.com/api/stripe/webhook
```

Subscribe it to these Checkout events:

```text
checkout.session.completed
checkout.session.async_payment_succeeded
checkout.session.async_payment_failed
checkout.session.expired
refund.created
refund.updated
refund.failed
```

4. Copy the webhook signing secret (it starts with `whsec_`) into the Worker:

```bash
npx wrangler secret put STRIPE_WEBHOOK_SECRET
```

5. Apply the payment migration and deploy:

```bash
npm run db:migrate:remote
npm run deploy
```

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.

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.

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.

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.

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.

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.

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.

#
## Booking management

Open a confirmed future booking in `/admin` to manage its time:

- **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.
- **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.
- **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.

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.

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.

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.

## Activity

Open `/admin` -> **Activity** for an all-time operational snapshot and recent lifecycle feed. The dashboard tracks:

- total bookings, upcoming meetings, completed meetings, cancellations, reschedules, paid bookings, and average meeting length
- 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
- recent booking confirmations, Checkout starts, payments, released holds, reschedules, suggested times, accepted suggestions, refunds, and cancellations

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.

## Attendees and booking search

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.

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.

## Google project in Testing mode

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.

## Later deploys

```bash
npm run deploy
```

Apply new D1 migrations when a release adds them:

```bash
npm run db:migrate:remote
```

## Local development

Copy `.dev.vars.example` to `.dev.vars` and fill in development credentials.

```bash
npm run db:migrate:local
npm run dev
```

For local Google OAuth, add the localhost callback you actually use to the OAuth client.

## Important implementation details

### Availability

Weekly availability is the outer boundary. Google Calendar is the source of truth for what is actually busy inside that boundary.

### Booking race protection

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.

The Worker then asks Google FreeBusy one final time before creating the event.

### Google tokens

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.

## Current limits

This is a focused first version, not a Cal.com clone.

- One owner/admin account
- Guest self-service management is booking-ID based rather than account based; the booking ID acts as a high-entropy bearer reference
- No recurring availability exceptions UI yet (block time in Google Calendar instead)
- Admin currently accepts calendar IDs as text rather than fetching a calendar picker
- No custom reminder emails; Google Calendar sends the invitation

Those are deliberate cuts to keep deployment and operation small.