Skip to content

Plan: CreatorTrack scheduling (Calendly-equivalent) + public handle namespace

URL: https://mkdocs.justinsforge.com/memory/plans/creatortrack-scheduling-handles-2026-07-13/

Date: 2026-07-13 Repo: /home/justinwieb/creatortrack (branch feat/scheduling)

Approved spec: Any CreatorTrack user with a connected Google account claims a handle (justin), then creates booking links at creatortrack.ai/<handle>/<slug>, permanent and sendable. A visitor with the link sees live open slots in their own timezone, picks one, answers the host's custom questions, and the event is written straight to the host's Google Calendar with confirmation emails to both sides via Resend. Slots render from the app.calendar_events cache for speed but are re-verified live against Google at write time; a taken slot fails the booking rather than double-booking. Every booking upserts a crm.contacts row and logs a meeting activity, and fires a booking.created automation event. Booking links are unlisted by default; a per-link public toggle and a per-link request/approval mode exist but are off. Handles live in one typed slug table, UNIQUE(handle_id, slug), so bio pages and booking links cannot collide. The links-edge Worker takes a wildcard route on the apex, does its KV lookup, and passes through to the Next origin on miss.

Out of scope (v1): - Payments / paid consults - Outlook and iCloud hosts (schema stays provider-agnostic, but only Google is wired) - Round-robin or multi-host links - Group / webinar events - Reschedule and cancel by the booker (v1: booker emails the host; host can cancel from their calendar) - Listing booking links on the bio page (the public toggle exists, the bio-page surface does not) - SMS reminders

Decided, do not relitigate: - URL is creatortrack.ai/<handle>/<slug>, creator picks the slug - Default mode is INSTANT booking, no approval step - Discoverability OFF by default - One typed slug table per handle, UNIQUE(handle_id, slug) - Reserved words at the HANDLE level only, short list - Worker wildcard on apex, pass through to Next on miss


Task list

Phase A: handle namespace

Task 1: Schema, handles + typed slug namespace

  • Files: /home/justinwieb/creatortrack/db/schema.ts, /home/justinwieb/creatortrack/db/migrations/<next>_handles.sql
  • What: Add core.handles (id, workspace_id FK, handle citext UNIQUE, created_at) and core.handle_slugs (id, handle_id FK, slug citext, kind enum bio_page|booking_link|shop, target_id bigint, created_at, UNIQUE(handle_id, slug)). Apply core.apply_workspace_rls to both. Handle regex ^[a-z0-9][a-z0-9-]{1,28}[a-z0-9]$.
  • Verification: psql lifeos -c "\d core.handles" && psql lifeos -c "\d core.handle_slugs" shows both tables with the unique constraint.
  • Commit: feat(handles): core.handles + typed handle_slugs namespace

Task 2: Reserved-word list + handle claim/validate lib

  • Files: /home/justinwieb/creatortrack/lib/handles.ts
  • What: RESERVED_HANDLES (login, signup, reset, verify, verify-signup, api, landing, product, pricing, brand, privacy, terms, marketing, help, certificates, account, app, admin, book, www, mail, static, assets). claimHandle(), isHandleAvailable(), resolveHandleSlug(handle, slug) returning {kind, target_id, workspace_id} or null. Reserved list is HANDLE-level only; nothing is reserved inside a handle.
  • Verification: cd /home/justinwieb/creatortrack && npx tsx -e "import {isHandleAvailable} from './lib/handles'; isHandleAvailable('api').then(console.log)" prints false.
  • Commit: feat(handles): reserved words + claim/resolve helpers

Task 3: Handle claim UI in Settings > Profile

  • Files: /home/justinwieb/creatortrack/app/(suite)/profile/page.tsx (or the settings profile component it renders), /home/justinwieb/creatortrack/app/api/handles/route.ts
  • What: Field to claim a handle, live availability check, inline error on reserved/taken. One handle per workspace. Changing a handle does not recycle the old one (keep the row, mark released_at).
  • Verification: claim a handle in the UI on a dev slot, then psql lifeos -c "select handle, workspace_id from core.handles" shows it.
  • Commit: feat(handles): claim UI + API

Phase B: booking data model + availability

  • Files: /home/justinwieb/creatortrack/db/schema.ts, /home/justinwieb/creatortrack/db/migrations/<next>_scheduling.sql
  • What: app.booking_links (id, workspace_id, host_user_id, slug, title, description, duration_min, buffer_before_min, buffer_after_min, min_notice_min, max_per_day, availability jsonb (weekly windows + host tz), busy_sources jsonb (array of <sourceKey>::<calId> to check for conflicts), write_target jsonb ({sourceKey, calendarId}), questions jsonb, mode enum instant|request default instant, is_public bool default false, is_active bool default true). app.bookings (id, workspace_id, booking_link_id, starts_at timestamptz, ends_at, booker_name, booker_email, booker_tz, answers jsonb, status enum confirmed|pending|cancelled|failed, provider text default 'google', provider_event_id text, provider_calendar_id text, contact_id FK crm.contacts nullable, created_at, idempotency_key text UNIQUE). Provider is a column, not an assumption, so Outlook slots in later. RLS via core.apply_workspace_rls.
  • Verification: psql lifeos -c "\d app.booking_links" && psql lifeos -c "\d app.bookings".
  • Commit: feat(scheduling): booking_links + bookings schema

Task 5: Slot computation from the events cache

  • Files: /home/justinwieb/creatortrack/lib/scheduling/slots.ts
  • What: computeSlots(link, fromDate, toDate). Expand the weekly availability windows in the host tz, subtract busy intervals read from app.calendar_events for every calendar in busy_sources, apply buffers, min notice, and the max-per-day cap (counting existing app.bookings for that link). Return UTC instants. Pure function over injected busy intervals so it is unit-testable without a DB.
  • Verification: cd /home/justinwieb/creatortrack && npx vitest run lib/scheduling/slots.test.ts (write the test in this task: a 9-5 Mon-Fri window with one 10:00 busy block yields no 10:00 slot, respects a 30m buffer, and honors min notice).
  • Commit: feat(scheduling): slot computation + unit tests

Task 6: Live conflict re-verification against Google

  • Files: /home/justinwieb/creatortrack/lib/google-calendar.ts, /home/justinwieb/creatortrack/lib/scheduling/verify.ts
  • What: Add freeBusy(sourceKey, calendarIds, timeMin, timeMax) to lib/google-calendar.ts (Google freebusy.query, no new scope needed, calendar write scope already covers it). verifySlotStillFree(link, startsAt) calls it live. This is the anti-double-booking guard and is called at write time, never at render time.
  • Verification: cd /home/justinwieb/creatortrack && npx tsx scripts/smoke-freebusy.ts (write it in this task) prints busy blocks for Justin's primary calendar for today.
  • Commit: feat(scheduling): live freeBusy re-verification

Phase C: the public route (the genuinely new surface)

Task 7: Unauthenticated route group + service-role data path

  • Files: /home/justinwieb/creatortrack/proxy.ts, /home/justinwieb/creatortrack/lib/db.ts (add asService() if not present), /home/justinwieb/creatortrack/app/(public)/[handle]/[slug]/page.tsx
  • What: Change the apex fallback in proxy.ts so an unknown top-level path is a handle lookup instead of an unconditional 308 to app.creatortrack.ai/login. Marketing allowlist and PUBLIC_PATHS behavior must be unchanged. Add (public) route group. There is no session on this route, so it resolves the workspace from the handle+slug via a service-role path that sets the workspace context explicitly, never asUser().
  • Verification: curl -s -o /dev/null -w '%{http_code}' https://<dev-slot>/justin/intro returns 200, and curl -sI https://<dev-slot>/pricing still serves marketing, and curl -sI https://<dev-slot>/nonexistent returns 404 (not a login redirect).
  • Commit: feat(scheduling): unauthenticated public route group + service data path

Task 8: Rate limiting + abuse hardening on the public surface

  • Files: /home/justinwieb/creatortrack/lib/ratelimit.ts, /home/justinwieb/creatortrack/app/api/public/bookings/route.ts
  • What: Per-IP and per-link rate limits on both the slot-list read and the booking write. Booking POST requires a short-lived signed token issued by the page render (stops naive scripted POSTs). Email format validation. Payload size cap. Fail closed with a uniform response so the endpoint is not an enumeration oracle for which handles exist.
  • Verification: for i in $(seq 1 30); do curl -s -o /dev/null -w '%{http_code} ' -XPOST https://<dev-slot>/api/public/bookings -d '{}'; done shows 429s after the limit.
  • Commit: feat(scheduling): rate limiting + abuse hardening on public booking API

Task 9: Booking page UI (the booker's view)

  • Files: /home/justinwieb/creatortrack/app/(public)/[handle]/[slug]/page.tsx, /home/justinwieb/creatortrack/components/public/BookingPage.tsx
  • What: Host name/avatar, meeting title, duration. Date picker plus slot list, rendered in the booker's detected timezone with an explicit tz selector. Custom questions form. Confirmation state. Uses the design system tokens; no login chrome, no app sidebar.
  • Verification: /screenshot https://<dev-slot>/justin/intro desktop + mobile, both render slots.
  • Commit: feat(scheduling): public booking page UI

Task 10: The booking write path

  • Files: /home/justinwieb/creatortrack/lib/scheduling/book.ts, /home/justinwieb/creatortrack/app/api/public/bookings/route.ts
  • What: Ordered and idempotent (keyed on idempotency_key): (1) verifySlotStillFree() live against Google, fail with a specific "that time was just taken" if not; (2) insert app.bookings row status confirmed; (3) pushEventToGoogle() into write_target with the booker as an attendee; (4) store provider_event_id; (5) on Google write failure, mark the booking failed and return an error to the booker rather than silently confirming. Never confirm a booking we could not write.
  • Verification: book a slot on a dev slot end-to-end, confirm the event appears on the real Google Calendar, then attempt to book the same slot again and confirm it is rejected.
  • Commit: feat(scheduling): booking write path with live conflict guard

Phase D: wiring into what already exists

Task 11: Confirmation emails via Resend

  • Files: /home/justinwieb/creatortrack/lib/email.ts
  • What: Add bookingConfirmationEmail (to the booker: what, when in their tz, who, an .ics attachment) and bookingNotificationEmail (to the host: who booked, their answers). Same template pattern as verificationEmail. Resend only, not Mautic.
  • Verification: cd /home/justinwieb/creatortrack && npx tsx -e "import {bookingConfirmationEmail} from './lib/email'; ..." sends a real test to [email protected].
  • Commit: feat(scheduling): booking confirmation emails

Task 12: CRM lead capture

  • Files: /home/justinwieb/creatortrack/lib/scheduling/book.ts, /home/justinwieb/creatortrack/lib/sales-crm.ts
  • What: On booking, upsert crm.contacts by email (the emails text[] GIN index already supports this), link bookings.contact_id, and log a crm.activities row of kind meeting. Do not auto-create a deal in v1; a deal is a decision, a meeting is a fact.
  • Verification: book a slot, then psql lifeos -c "select c.id, c.emails, a.kind from crm.contacts c join crm.activities a on a.contact_id=c.id order by a.created_at desc limit 1".
  • Commit: feat(scheduling): upsert booker into CRM + log meeting activity

Task 13: booking.created automation event

  • Files: /home/justinwieb/creatortrack/lib/automations.ts, /home/justinwieb/creatortrack/lib/scheduling/book.ts
  • What: Register booking.created in the automations event catalog (currently only task.created / task.updated) and call fireAutomations from the booking write path. Respect the existing AsyncLocalStorage depth guard (MAX_DEPTH 5).
  • Verification: create an automation on booking.created with a notify action, book a slot, confirm the notify fires.
  • Commit: feat(scheduling): booking.created automation trigger

Task 14: Dead-token detection and surfacing

  • Files: /home/justinwieb/creatortrack/lib/google-connections.ts, /home/justinwieb/creatortrack/lib/scheduling/slots.ts
  • What: If the host's Google refresh token is dead or revoked: the public booking page shows "this host's calendar is temporarily unavailable" and offers no slots (it must never accept a booking it cannot write), and the host gets a notify plus a persistent banner in the app. Fail loud, per doctrine.
  • Verification: revoke a test connection's token, load its booking page, confirm no slots render and the host banner appears.
  • Commit: fix(scheduling): fail loud on dead Google tokens instead of silently dropping bookings

Phase E: host-side config

  • Files: /home/justinwieb/creatortrack/app/(suite)/[ws]/scheduling/page.tsx, /home/justinwieb/creatortrack/components/apps/scheduling/LinkEditor.tsx, /home/justinwieb/creatortrack/app/api/apps/scheduling/route.ts
  • What: Create/edit a booking link: slug (validated against core.handle_slugs, inline "that URL is taken"), title, duration, weekly availability grid, buffers, min notice, max/day, multi-select of which calendars block me (from the cached calendar_source_calendars), single-select of which calendar to write to (from listWritableCalendars(), writable only), custom questions, mode (instant default / request), is_public toggle (default off, with copy making clear that ON means anyone who finds the URL can book). Copy-link button.
  • Verification: create a link in the UI, confirm the row in app.booking_links and that its public URL renders.
  • Commit: feat(scheduling): booking-link editor

Task 16: Request/approval mode

  • Files: /home/justinwieb/creatortrack/lib/scheduling/book.ts, /home/justinwieb/creatortrack/components/apps/scheduling/RequestQueue.tsx
  • What: When mode='request', the write path stops after inserting app.bookings with status pending. Nothing is written to Google. The booker sees "you'll hear back." The host gets a notify and an approve/decline queue; approving runs the same live-verify + Google-write path as instant. This is the option, not the default.
  • Verification: set a link to request, book it, confirm no Google event exists and a pending row does; approve it, confirm the event appears.
  • Commit: feat(scheduling): request/approval mode

Phase F: edge routing

Task 17: Worker wildcard on the apex with origin passthrough

  • Files: /home/justinwieb/forge/infra/links-edge/wrangler.toml, /home/justinwieb/forge/infra/links-edge/src/index.ts, /home/justinwieb/forge/scripts/forge_links_routes_reconcile.py
  • What: Add a creatortrack.ai/* route. In the Worker, do the existing KV page:<host>:<slug> lookup; on miss, fetch() the Next origin and return its response unchanged. Booking pages are never in KV so they always fall through to Next and get live availability. The marketing site, the app redirects, and /api/* must all pass through untouched. Update the reconciler's apex guard, which currently refuses an empty-slug route specifically because <host>/* would swallow the zone; that guard is now intentionally lifted for this zone only, and the passthrough is what makes it safe.
  • Verification: after deploy, curl -sI https://creatortrack.ai/pricing still serves marketing, curl -sI https://creatortrack.ai/justin/intro serves the booking page, curl -sI https://app.creatortrack.ai/login is unaffected.
  • Commit: feat(links-edge): apex wildcard with origin passthrough for CT public pages

Risk note: this task can take down the marketing site and the login redirect if the passthrough is wrong. Deploy it behind a check, verify all three curls above immediately, and be ready to wrangler rollback. Remember the standing gotcha: wrangler deploy WIPES API-added page routes, so run scripts/forge_links_routes_reconcile.py afterward.


Task 18: Register and document

  • Files: /home/justinwieb/forge/MEMORY.md, /home/justinwieb/forge/memory/general/reference_creatortrack_scheduling.md, /home/justinwieb/forge/memory/general/reference_creatortrack_handles.md
  • What: Topic file for the scheduling module (URL model, the anti-double-booking guard, the dead-token failure mode, the booking.created automation event) and one for the handle namespace (reserved list, the typed slug table, why there is no reserved word inside a handle). Index entries in MEMORY.md, one line each.
  • Verification: bash /home/justinwieb/forge/scripts/forge_eval_run.sh
  • Commit: docs(scheduling): register scheduling + handles in memory

Sequencing notes

  • Tasks 1-3 (handles) gate everything else; the URL cannot exist without them.
  • Tasks 4-6 can run in parallel with 1-3 once the schema lands.
  • Task 7 is the risky one. It is the first unauthenticated writable route CT has ever had. Do not rush it.
  • Task 17 is the dangerous one. It can break the marketing site and login. It is deliberately last, so everything else is proven on a dev slot first.
  • Ship to prod via /ship, which handles the migration gate.