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) andcore.handle_slugs(id, handle_id FK, slug citext, kind enumbio_page|booking_link|shop, target_id bigint, created_at,UNIQUE(handle_id, slug)). Applycore.apply_workspace_rlsto 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)"printsfalse. - 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¶
Task 4: Schema, booking links + bookings¶
- 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 enuminstant|requestdefaultinstant, 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 enumconfirmed|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 viacore.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 fromapp.calendar_eventsfor every calendar inbusy_sources, apply buffers, min notice, and the max-per-day cap (counting existingapp.bookingsfor 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)tolib/google-calendar.ts(Googlefreebusy.query, no new scope needed,calendarwrite 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(addasService()if not present),/home/justinwieb/creatortrack/app/(public)/[handle]/[slug]/page.tsx - What: Change the apex fallback in
proxy.tsso an unknown top-level path is a handle lookup instead of an unconditional 308 toapp.creatortrack.ai/login. Marketing allowlist andPUBLIC_PATHSbehavior 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, neverasUser(). - Verification:
curl -s -o /dev/null -w '%{http_code}' https://<dev-slot>/justin/introreturns 200, andcurl -sI https://<dev-slot>/pricingstill serves marketing, andcurl -sI https://<dev-slot>/nonexistentreturns 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 '{}'; doneshows 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/introdesktop + 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) insertapp.bookingsrow statusconfirmed; (3)pushEventToGoogle()intowrite_targetwith the booker as an attendee; (4) storeprovider_event_id; (5) on Google write failure, mark the bookingfailedand 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) andbookingNotificationEmail(to the host: who booked, their answers). Same template pattern asverificationEmail. 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.contactsby email (theemails text[]GIN index already supports this), linkbookings.contact_id, and log acrm.activitiesrow of kindmeeting. 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.createdin the automations event catalog (currently onlytask.created/task.updated) and callfireAutomationsfrom the booking write path. Respect the existingAsyncLocalStoragedepth guard (MAX_DEPTH 5). - Verification: create an automation on
booking.createdwith anotifyaction, 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¶
Task 15: Booking-link editor UI¶
- 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 cachedcalendar_source_calendars), single-select of which calendar to write to (fromlistWritableCalendars(), writable only), custom questions, mode (instantdefault /request),is_publictoggle (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_linksand 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 insertingapp.bookingswith statuspending. 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 apendingrow 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 KVpage:<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/pricingstill serves marketing,curl -sI https://creatortrack.ai/justin/introserves the booking page,curl -sI https://app.creatortrack.ai/loginis 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.createdautomation event) and one for the handle namespace (reserved list, the typed slug table, why there is no reserved word inside a handle). Index entries inMEMORY.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.