Skip to main content

— Changelog

What changed,
in order of arrival.

One release per roadmap lot. The tagged versions match the milestones at docs.caledee.

Subscribe via RSS

Release notes are published in English.

  1. Version 0.41.3

    security lot, calendar multi-account, group seats, account deletion, growth programs

    Two migrations (20260923115702_booking_external_event_connection, …_affiliate_ledger_outlives_user; prod blast radius measured: 7 external-event rows, 4 backfilled, 0 affiliate rows). 515 unit + 185 integration green, typecheck + lint 15/15, audit:critical gate green.

    — Fixed — referral & affiliate programs (first run of the new `referral-affiliate-audit`), tests first

    • An affiliate referral is no longer lost when the referee upgrades later (RA-1, P0). The nightly job voided — permanently — any referral whose first payment hadn't arrived 14 days after SIGNUP; with the 14-day trial on every checkout that was almost every referral, and every pre-go-live referral on go-live night. A referral now stays pending until it pays; each commission is held 14 days from its own invoice. Referrals an earlier run voided automatically re-open on their next paid invoice; an admin void stays final.
    • One payout button, with the amount on it (RA-2, P0): "Mark paid" and "Pay recurring" could pay the first invoice's commission twice. The ledger of approved commissions is the only payable unit, and the audit row records the cents.
    • Google, Microsoft and magic-link signups keep their referral and affiliate attribution (RA-3): only the password signup read the cookies.
    • A refund claws back that invoice only (RA-4): any refund, at any time, used to void every unpaid commission and stop the affiliate earning forever, and reverse a referral credit already released months earlier. Now a full refund voids that invoice's commission, a partial refund shrinks it, and a referral is clawed back only when its first invoice is refunded while the reward is still held.
    • Chargebacks are handled (RA-5): an opened dispute holds the invoice's commission, a won one releases it, a lost one keeps it void and claws the referral back. charge.dispute.closed is now subscribed.
    • Voiding a conversion in the admin voids its unpaid commissions (RA-6) and a void conversion can't be paid.
    • Deleting an affiliate's account keeps the commission ledger (RA-7): paid-out commissions used to vanish from the finances. Migration …_affiliate_ledger_outlives_user (AffiliateAccount.userId nullable + SetNull, conversions/commissions RESTRICT); the deleted user's affiliate link is suspended. New guard: test/integration/affiliate-lifecycle.test.ts (14; the RA-1 cases proven red against the old engine). Open, tracked in the audit: self-referral detection (RA-8), an admin suspend lever / program kill switch (RA-9), click-rate limiting (RA-10), the commission base (VAT-inclusive today — a pricing decision, see payments-audit N14).

    — Fixed — sign-in refusals are explained (follow-up to the security lot)

    • A refused Google / Microsoft / magic-link sign-in lands back on the sign-in page with a translated explanation instead of Auth.js's unbranded English "Access Denied" page; an expired or reused sign-in link says so.
    • The magic-link form no longer shows an unexplained "check your inbox" when no email is sent. The confirmation stays neutral (so the form can't be used to find out which accounts have two-factor on or are suspended) and now always adds that accounts with two-factor authentication sign in with their password and code; a real sending failure shows a retry message instead of the button label.
    • Two-factor settings and the admin suspend panel say what they actually do: 2FA turns off emailed sign-in links and doesn't apply to Google/Microsoft sign-in; suspension signs the person out everywhere, blocks API tokens and connected apps, and takes their booking pages offline.

    — Fixed — group bookings + account deletion lot (2026-09-23 audit waves), tests first

    • A group invitee's cancel link frees THEIR seat, not the whole class. On a group event type the link used to cancel the entire booking: every invitee emailed, the host's calendar event deleted. Now only that invitee is canceled (they + the host are told, they're removed from the host's calendar event, the waitlist hears about the free seat); the booking is canceled when the last seat is freed. Poll-finalized meetings keep whole-meeting semantics.
    • A group invitee's reschedule link moves THEIR seat to the new time (joining a group there or opening a new booking) instead of moving everyone.
    • Joining a group no longer re-announces the whole booking. Each join used to re-send every earlier invitee's confirmation, create a second host calendar event (with a new Meet link overwriting the stored one) and fire a webhook about the first invitee. The joiner now gets their confirmation, is added to the host's existing event, and the host / webhook / CRM hear about them (booking_created payload names the joiner).
    • A freed seat reopens. Canceled invitees kept counting against the group's capacity, in booking and in slot computation.
    • Paid + group is refused (payments-audit N2, P0): one Payment per booking handed the second invitee the first one's payment. Saving a paid group event type is refused, and a second invitee on an existing paid group slot gets "slot unavailable". Prod: 1 group event type, unpaid — nothing affected.
    • Deleting an account cancels its upcoming meetings instead of silently erasing them (behavior-audit D1, P0): invitees (and a teammate who hosted on the user's event type) are emailed before the delete, calendar events removed, payments refunded, calendar connections disconnected (watch channels stopped, Google token revoked). The waitlist of a disappearing event type isn't pinged.
    • Account deletion no longer fails after Stripe was already canceled (D2): meetings other hosts held on the user's event types blocked the delete on a foreign key. The sequence now lives in lib/account-deletion.ts with every refusal before anything irreversible. New guards: test/integration/group-seats.test.ts (7) and test/integration/account-deletion.test.ts (2, the FK case proven to fail without the fix).

    — Fixed — calendar multi-account lot (behavior-audit 2026-09-23 C1/C2), tests first

    • A host with two Google (or two Microsoft) accounts no longer has real bookings auto-canceled. The two-way sync for account B read every Google event of the user with B's token; an event living on account A came back 404, which reads as "the host deleted it", so the booking was canceled — invitee email, refund, webhooks — up to 100 per webhook. Every external calendar event now records the exact connection that wrote it (BookingExternalEvent.connectionId, migration 20260923115702), and the sync only reconciles events written by the account whose calendar changed. Prod had no such host yet (0 bookings canceled this way).
    • Cancel and reschedule delete the calendar event with the account that wrote it. They used the oldest account of the provider; a 404 counts as success, so the event stayed on a pinned calendar and a reschedule left both times on the host's and the invitee's calendars.
    • Migration backfill: Microsoft rows matched exactly by account email, any provider when the owner has exactly one account of it; the rest stay unknown, are never reconciled (the safe side) and are deleted the legacy way.

    — Fixed — sign-in loop (redirects-audit 2026-09-23 P0)

    • No more endless /dashboard ↔ /login loop after a password change, "log out everywhere", a password reset or a suspension. The session gate returned an empty token, which Auth.js re-signs as a signed-in session with no user; the app bounced to /login and the sign-in pages bounced back. It now returns null, which clears the cookie, and the sign-in pages only redirect a session that resolves to a user.

    — Security — lot from the 2026-09-23 Opus 5.5 audit wave, tests first

    • Dependencies: 12 → 0 critical advisories. next 15.5.18 → 15.5.26 (unauthenticated RCE in the image optimizer, GHSA-2xp9-vwfh-vxw4, plus the flight-protocol RCE and middleware bypass via the react-email preview CLI's bundled Next 15.1.2), sharp 0.34.5 → 0.35.4, next-auth beta.22 → beta.32 and @auth/core 0.41.3 (email-normalizer + configuration-error advisories), vitest 2 → 3.2.6 (UI-server file read), tar / shell-quote overrides for the dormant mobile app. The unused react-email CLI dependency is removed.
    • Uploads accept ISO-BMFF only for real video brands. Any file with ftyp at byte 4 was stored as video/mp4, so an AVIF/HEIF image uploaded by a free account reached the image optimizer.
    • CI gates every deploy on critical advisories (audit:critical, validate stage). The only audit was the weekly, allow_failure, || true scheduled scan.
    • Suspension is enforced everywhere, not just on the password form. A suspended account is refused on Google/Microsoft and magic-link sign-in, its live sessions end on the next request, its personal access tokens and OAuth tokens stop working, and its public booking pages and booking endpoint return 404.
    • A magic link no longer bypasses 2FA. Accounts with two-factor on must sign in with their password + code; the link isn't sent.
    • Revoking an OAuth app kills its live access tokens (refresh was already refused; access tokens stayed valid until expiry — oauth-audit 2026-09-22 P1). New guards: test/integration/suspension.test.ts (8, auth callbacks extracted to lib/auth-callbacks.ts so they run against a real DB) and the MP4-brand case in lib/uploads.test.ts, each proven red first.
  2. Version 0.41.2

    redirects P1s + group-join PII leak

    No migration. 514 unit + 151 integration green, tsc 0, turbo lint 9/9.

    — Fixed — group bookings (2026-09-23 Opus 5.5 audit wave, payments N8)

    • An invitee joining a group slot gets their own confirmation. The second invitee of a group event received the first invitee's name, email, cancel link and reschedule link back from the booking request, and /c/{token} + /r/{token} opened with the joiner's own token rendered the first invitee. The requesting invitee is now the one every confirmation and token page speaks to. Still open (next lot): one invitee's cancel cancels the whole group, and a join re-runs every "booking created" effect for the whole booking.

    — Fixed — redirects P1s (the 2026-09-22 Opus audit wave), tests first

    • /fr/login, /es/login and /fr/admin/ no longer 404. The locale rewrite was gated on the *maintenance exempt list (/login, /admin, /api/auth…), so exactly those paths fell through as literal, non-existent routes — and since ADR-0026 the marketing site's FR/ES pages are the entry point to sign-in.
    • /fr/ and /es/ on a host's custom domain serve the host's booking pages, not Caledee's own pages. The locale branch returned before the custom-domain map was applied (book.galaxee.fr/fr rendered the French sales landing). Locale strip → custom-domain map → maintenance gate now compose in one pure, unit-tested decision (lib/request-routing.ts).
    • Maintenance mode applies to locale-prefixed URLs. Found while fixing the above: the same early return skipped the maintenance gate for every /fr/ and /es/ request.
    • The routing-form "You're about to leave Caledee" page can no longer be forged. It rendered a branded "Continue to {any https host}" button for any /f/{a}/{b}/redirect?to=…, even for forms that don't exist. The link now carries the submission id (?s=) and the page shows the target stored on that submission of that active form, or bounces to the form.
    • A repeated ?s= on that page bounces to the form instead of a 500 (found by the 2026-09-23 errors audit on the fix above).
    • A host whose session expires mid-Stripe-Connect onboarding lands back on /connections, not the (now marketing) /integrations sales page. New guards: lib/request-routing.test.ts (13) and test/integration/routing-interstitial.test.ts (3), each proven red first.
  3. Version 0.41.1

    the 2026-09-22 audit wave's two P0s

    No migration. 501 unit + 147 integration green, tsc 0, turbo lint 9/9.

    — Fixed — P0 lot (the 2026-09-22 Opus audit wave's two P0s), tests first

    • A rescheduled PAID booking keeps its payment — so cancelling it actually refunds (behavior-audit F1, P0). Payment is strictly 1:1 with Booking (Payment.bookingId @unique), and rescheduleBooking cancels the old row then mints a new one. It never touched Payment, so the money stayed bound to the canceled booking: the moved meeting lost its paid/refunded chips, dropped out of the no-show-revenue join, and refundBookingPayment(newId) hit a silent if (!payment) return. Pay → reschedule → cancel meant the invitee was charged, the meeting was gone, and nothing was refunded or logged. The payment now moves inside the reschedule transaction, and a refund that finds no payment on a paid event type warns + reports to Sentry instead of returning silently. Prod carries no Stripe keys yet, so no real money was affected.
    • A booking still awaiting payment can no longer be rescheduled (behavior-audit F2, P1, same code path). It would have produced a confirmed meeting for free plus an orphan PaymentIntent the 30-minute reaper could never see again. It now returns 409; the invitee pays, or lets it expire, then books freely.
    • The booking-reminder email's join button is localized (i18n-audit P0-1). emails/booking-reminder.tsx shipped the hardcoded French literal Rejoindre la réunion to EN and ES invitees inside an otherwise correctly translated email, while email.json → reminder.join existed in all three locales and was referenced by zero call sites. Every existing guard passed it — locale parity, placeholder parity, namespace wiring and the code→messages check all succeed because an unused key is never a missing key. New guards: test/integration/reschedule-payment.test.ts (4) and emails/booking-reminder-locale.test.tsx (5), each proven red first.
  4. Version 0.41.0

    ADR-0026 step 4: the platform no longer ships the marketing site

    The marketing container (caledee-mkt-prod, marketing repo master 5ac45ab) has served every public marketing path on caledee.com since 2026-09-21 (Traefik priority 200 over the platform's 100). This release removes the platform's own copy. No migration. 496 unit + 143 integration green (13 tests left with the marketing group: its nav-config test + the 3 namespaces' parity cases), tsc 0, lint clean, next build green without MDX.

    — Removed

    • apps/web/app/(marketing) (75 files: home, pricing, features, integrations, about, ai, affiliates page, blog, docs, help, /vs, /for, legal, status, changelog), the i18n namespaces marketing / help / docs (EN/FR/ES), app/sitemap.ts, app/robots.ts, app/changelog.xml/, mdx-components.tsx, types/mdx.d.ts, the MDX pipeline in next.config.mjs (pageExtensions no longer includes mdx) and the deps @next/mdx, @mdx-js/loader, @mdx-js/react, remark-gfm, rehype-slug, rehype-autolink-headings, rehype-pretty-code, shiki.
    • public/screenshots, public/guides, public/llms.txt (byte-identical copies already live in the marketing repo; public/embed.js stays).
    • The dead .github/workflows/ci.yml (no GitHub remote; CI is GitLab).
    • lib/marketing-pages.ts: the Prisma + unstable_cache gate (marketingSlugForPath, marketingPageOffline, MARKETING_FLAGS_TAG) — the marketing container enforces the offline flag from GET /api/public/site-meta. The MARKETING_PAGES registry + MARKETING_FLAG_PREFIX stay (source of truth for /admin/marketing, site-meta.routes and the generated Traefik rule).

    — Changed

    • app/page.tsx → redirect("/login"). Only reached if the marketing container is down (custom domains rewrite / to /u/{slug} before).
    • /admin/marketing documents the ≤ 5 min propagation (site-meta revalidate) and no longer calls a consumer-less revalidateTag; its "view" links use prefetch={false} (cross-app).
    • i18n/code-key-coverage.test.ts anti-vacuity floors re-based (>200 files, >2000 keys — now 340 / 3155).
    • scripts/capture-tutorial-screenshots.mjs keeps only platform surfaces; .dockerignore loses the (marketing)/docs/*/.mdx exception.
    • Docs: docs/INVENTORY.md (public-docs section now points to the marketing repo), docs/operations/custom-domains.md (300/200/100/1 priorities), vault/infra/runbook.md § Rollback (per-container; compose down of marketing is no longer a rollback), ADR-0026 + vault/domain/architecture/marketing-split.md refreshed.
  5. Version 0.40.1

    money lot + silent-failures lot (the 2026-09-20 audit wave's P0/P1s)

    Everything below was written tests first (each guard verified red before the fix). No migration. 509 unit + 143 integration green, tsc 0, lint clean. Prod has no Stripe keys yet, so the money fixes land before they can bite; the workflow / reschedule / webhook fixes change behaviour for real hosts today.

    — Fixed — silent-failures lot

    • The three ghost workflow triggers fire (e2a9d6b, wiring F1 P1 + F5 P2): booking_canceled, no_show and booking_created → in-app never reached the queue — enqueueReminder dropped any job with delay <= 0, which every event-driven dispatch passed; new enqueueReminderNow(). Reminder job ids carry trigger + offset (a 24 h and a 1 h reminder on the same booking collided, BullMQ kept the first). The reminders worker refused non-active bookings (cancel flips status before dispatch) and dropped anything after the start (a no-show is after the start by definition): jobs now carry trigger. The server schema accepted 4/5 triggers and 4/5 actions while the builder offered 5/5 (no_show, notify_host_inapp saves failed with a Zod error).
    • Reschedule with invitee-chosen location no longer 400s; the moved booking isn't busy for its own move (544bccf, wiring F2 P1 + F3 P2): locationIndex derived from the old booking's kind/value; excludeBookingId threaded into computeBookableSlots (with dailyLimit 1 or buffers the invitee could not move the booking at all).
    • Outbound webhooks: one 4xx no longer disables a subscription; failures counted per job; host notified on disable; redirects never followed (771a2ea, api-webhook P2-3 + security P2-4).
    • No more silent failures on member departure, auth emails and the root error boundary (3819ea1, errors P1-5/P1-6): a failed reconcileMemberDeparture is captured and ABORTS the removal; verify/reset/resend email failures are captured; app/error.tsx captures client render errors.
    • One-off picks can't overlap or cross midnight; event-type custom hours block Save and reject overlaps server-side (0d76309, forms P1-1/P1-2/P1-3).

    — Fixed — money lot

    • Seats never drift silently (0bffcf7, errors-audit P0 + payments F1/F3/F4): syncSeatCount captures to Sentry and THROWS on a Stripe failure instead of console.error + return (the owner saw "member added" while Stripe kept the old quantity); increases invoice the proration immediately (always_invoice — on yearly Team a seat added was only collected at the next renewal, i.e. never under cancel-at-period-end); membership flows notify the owner in-app on failure; a daily billing:seat-resync scheduler converges every live Team sub. applyStripeSubscriptionToDb persists Stripe's quantity as seatCount and re-syncs after an active Team upsert (checkout always starts at 1 seat). isTerminal = canceled | incomplete_expired — a Portal cancel-at-period-end that had ended stayed stored as pro/team; /settings/billing derives the shown plan from effectivePlan and shows a "subscription ended" badge (EN/FR/ES).
    • The org owner cannot be demoted or removed by an admin (9d694ea, security P2-1): fence keyed on Organization.ownerId; first server-action tests.
    • Deleting an account cancels the Stripe subscription first (f3bf2ea, payments F2): subscriptions.cancel(invoice_now, prorate) before user.delete, deletion REFUSED if Stripe fails (no orphan paying customer); the Customer is kept but marked; owners of the orgs the user belonged to get their seats re-synced.
    • Stripe webhook scope routing (a850791, security P2-3, payments F7/F8, errors P1-4): Connect-scope customer.subscription. / invoice. are ignored (a connected host could forge metadata.userId); platform-scope payment_intent.* are ignored (subscription charges only fed the ORPHAN alarm) and dropped from the setup script's platform list; subscription events apply subscriptions.retrieve(id) so a stale retry can't overwrite newer state; account.updated no longer swallows a failed Connect refresh (500 + dedupe rollback → Stripe retries). First route tests.
    • Paid bookings are confirmed only when paid (19181a0, payments F5, open since 05-24; api-webhook P2-1): the row holds the slot, every outward effect (calendar, confirmation emails, booking_created webhook, CRM, workflows, bell) runs from payment_intent.succeeded (replay-safe); a booking:unpaid-reaper scheduler cancels silently after 30 min and voids the PaymentIntent; hosts see an "awaiting payment" chip; POST /api/v2/bookings refuses paid event types (they were confirmed and never charged). Host book-on-behalf / poll paths keep the immediate confirmation (paymentSettled).
    • Customer Portal configuration exists (36ecc02, payments F6): created in test mode (default; price switches among the 4 prices, quantity OFF, cancel at period end + reason survey), reproducible by scripts/stripe-live-setup.mjs, optional STRIPE_PORTAL_CONFIG_ID pin. /api/billing/portal 500'd before (0 configurations). _Still open from the payments audit (P2, next lot): unlimited re-trials (F9), second subscription for a past_due user (F10), no dunning email (F11), tax_behavior unspecified + Stripe Tax pending (F12), lost disputes (F13), runbook (F14)._ _[0.40.0] below is live in prod (2026-09-21, c34e19a, tag v0.40.0, verified: health, site-meta, 39 changelog items, www 301, uploads volume, worker healthy)._
  6. Version 0.40.0

    marketing-site split, platform side (ADR-0026) · uploads persist across deploys · two prod bugs fixed (www, empty changelog)

    The headline is ADR-0026: the marketing site (home, pricing, blog, docs, help, /vs, /for, legal) now lives in its own repo + container (galaxee-group/caledee-marketing), served on the same domain by Traefik path routing. This release is the platform side of that split — additive only: the public contract the marketing container reads, explicit router priorities, and the www redirect. app/(marketing) is still shipped here and still answers on custom host domains; it is removed in the next release once the marketing container is live in prod. Also ships the uploads volume (logos, covers, file-question answers no longer vanish on deploy) and the extended site-meta contract. No migration. 452 web unit + integration green, tsc 0, turbo lint clean.

    — Added

    • GET /api/public/site-meta (34ca18c, 85fa522) — public, cached, rate-limited: plan prices, pages taken offline by an admin, the list of marketing routes, the live plan-gate table (featureGates()), free limits, and coarse boolean capabilities (stripe / sms / calendars / zoom / hubspot / slack / referral). The marketing repo's CI (check:site-meta) fails on price or route drift against it. Nothing here is secret: booleans only, no key names or values.
    • GET /api/public/changelog (34ca18c) — the parsed repo-root CHANGELOG.md, so the marketing site renders /changelog + changelog.xml without a copy of the file.
    • www.caledee.com → 301 apex (8780c11) — no redirect existed (the DNS note claimed one); router caledee-www at Traefik priority 300.
    • Uploads persist across deploys — named volume caledee_uploads_{prod,staging}_data mounted on web at UPLOADS_DIR=/app/apps/web/uploads, dir created in the image with the runtime user's ownership. The one file uploaded in prod since v0.39.0 was seeded into the volume before this deploy. Roadmap BLOQUANT item closed.

    — Changed

    • Traefik router priorities are explicit (8780c11): platform caledee-prod/caledee-staging = 100 (the marketing path router takes 200, the custom-domain catch-all stays 1). Apex traffic is unchanged until the marketing container is deployed.
    • Cross-app links carry prefetch={false} (1ee053f) — once /login, /signup, /settings/* belong to a different Next build, the RSC prefetch is wasted (Next falls back to a full navigation on a foreign build ID). X-Served-By: platform header added so verify-routing.sh can prove which container answered.
    • e2e smoke targets /login + /api/health (the platform no longer owns / after the split).
    • Agent fleet re-scoped (3b2b913): marketing-director, marketing-audit, seo-content-strategist + the comparison skill moved to the marketing repo; 18 platform briefs shrunk or repointed; lot-finalizer gained the cross-repo contract check (9b).

    — Fixed

    • /changelog.xml shipped EMPTY in prod for months (bb85cb7): .dockerignore's */.md excluded CHANGELOG.md and the runner stage never copied it, so readChangelog() returned [] inside the container (486 bytes, 0 items, verified 2026-09-20). Now !CHANGELOG.md + an explicit COPY; 38 entries render.
  7. Version 0.39.0

    one-off WeekSlotGrid brought to production quality (13-item Fable UX review) · payments/pricing/wiring P2 batch · ops daily health email · Sentry money-path capture

    The headline is the one-off WeekSlotGrid picker — the weekly Google-Calendar-style time grid that replaced the month + time-column picker (introduced v0.37.0, 51c24da) — hardened to production quality by a Fable UX review: 12 of 13 items actioned (#14 hover-render perf deferred, "measure first"). Folded in alongside it, the previously-un-changelogged post-v0.38.0 P2 batch (payments · pricing · custom-domain wiring · dashboard · i18n guard · errors-audit Sentry capture) and the new ops daily-health-email. The PickedSlot {date,start} contract stayed stable, so lib/one-off.ts and the scheduling engine were untouched. No migration. Gated by 446 web unit + integration green, tsc 0, turbo lint clean on every commit.

    — Added

    • One-off WeekSlotGrid — full keyboard + screen-reader access (e7c895b, UX audit P0 #2). Every day column now carries a "+ Add" button (also the empty-state affordance + Calendly parity): first press places the day's first in-hours snap (else 09:00), repeats append after the last slot by one duration; placed-slot chips take ArrowUp/Down to nudge ±30 min (Enter still removes); a polite role="status" live region announces adds/nudges. The core "add a slot" action is no longer pointer-only — closing a hard WCAG 2.1.1 failure. i18n EN/FR/ES at parity.
    • One-off WeekSlotGrid — a review popover before the host sends the link (a575874, UX audit #4/#11). The dead slot-counter became a disclosure ("5 créneaux · 2 jours ⌄") that opens a popover listing every pick grouped by day, each a removable chip ("12:00 – 12:30 ×"), the day heading jumping the grid to that week — the verification step before a host shares the link. Plus an exact week-range label via Intl.formatRange ("20 – 26 juil. 2026" / "29 juin – 5 juil. 2026", ending the anchor-month lie on a straddling week) and an "Aujourd'hui" button that appears only once navigated away.
    • Authenticated ops daily-health-email to every prod admin (529dbe8, new lib/ops-report.ts + worker scheduler ops:morning-report). A BullMQ repeatable job (05:00 UTC ≈ 07:00 Europe/Paris) builds the morning health report — db/redis health, worker heartbeat, paused-active links, lapsed subs, bookings, Sentry/Better Stack counts, build SHA (non-sensitive aggregates only, never PII) — and emails a calm on-brand report (subject telegraphs 🟢 all-good vs 🟠 look-at-this so an admin triages from the inbox) to every User.isAdmin. Self-maintaining: a new admin gets it, a removed one stops. buildOpsReport() is now shared with the existing GET /api/ops/morning-report endpoint (one source of truth); the endpoint is thin (auth + delegate). New ops-report email registered in the catalog. Takes effect once deployed to prod (the worker registers the scheduler on boot); the Sentry/Better Stack rows read "not_configured" until those read tokens land in prod env.
    • CRM-sync (HubSpot) row on the pricing comparison table (1c36aca, plan-parity P2). crm_sync is Pro+ and enforced, and HubSpot is marketed on /integrations, but the comparison table had no CRM row — a Free user saw the integration advertised, tried it, and hit an undisclosed Pro wall. Added a CRM-sync row (Free ✗ / Pro+ ✓), i18n EN/FR/ES at parity.
    • i18n code→messages CI guard (Dim-7, the last unguarded dimension) (1c44ccf). The locale-vs-locale parity guard structurally cannot catch a key missing in all three locales — the exact shape that leaked onboarding.seed. to prod (2026-05-29) and came within a live-timing coincidence of leaking trialNudge. (2026-07-24). The new guard extracts every literal t("…")/tc("…") dotted key from app + component source (2565 unique keys scanned) and asserts each exists somewhere in messages/en; an anti-vacuity assertion guards a broken/empty regex, and a synthetic missing key is verified to fail. Closes the last i18n dimension that has bitten prod.

    — Changed

    • One-off WeekSlotGrid — touch input is now scroll-safe (e7c895b, UX audit P0 #1). onPointerDown placed a slot immediately, so on a phone every scroll flick across the grid silently added a time the host never chose — and mailed it out in the link. Input is now pointer-type-aware: mouse/pen keep the immediate drag-paint; touch places nothing on down and decides on up (a tap within a 10px slop places; any real movement is a scroll, abandoned so the browser pans natively, no pointer capture on touch). Added onPointerCancel cleanup; the sticky hover ghost is suppressed on touch.
    • One-off WeekSlotGrid — off-hours hatch, mobile chips, and the hour gutter are legible & locale-aware (a575874/69afa21, UX audit #7/#10/#12/#13). The off-hours hatch was 4.5% in the grid but 12% in the legend (the key didn't match the thing it explained) → unified to 8% light / 10% dark; a host with no weekly availability no longer gets the whole grid hatched (empty availability = unknown = no hatch, which had read as "something is broken"); below sm a chip shows the start only ("12:00") instead of truncating "12:00 – 12:30" to garbage; the hour gutter is now locale-aware via Intl (an en-US host sees "8 AM", not a hardcoded "08:00") and the tz note renders "America/New York" instead of the raw underscored IANA id.
    • Custom domain now honored across the public API / MCP / Zapier / routing-form link-gen (0c5e688, wiring M3). Custom domains were verified + inbound-routed + honored by ~16 link-gen sites, but four secondary output consumers still hardcoded the canonical host — so a host who verified book.example.com still received caledee.com/u/... from GET /api/v2/event-types (the event-type url), the list_availability Zapier action (booking_url), the MCP get-booking-link tool, and routing-form redirect targets. All four now resolve getVerifiedCustomDomain(ownerId) once and build via buildBookingUrl, the same helper the public pages use (routing-forms threads the owner id). Consumer coverage for the custom-domain capability is now complete across the public API + assistant/automation surfaces.
    • Dashboard — the onboarding checklist is above the fold (b002bbe, UX audit #4). For an un-activated host the checklist sat low in a 2-col grid — on mobile it linearized ~7 screens down (hero → glance → agenda → 3 stat cards → event-types → THEN the checklist), so the one thing a new host needs to act on was the last thing they saw. It's now full-width right under the header on mobile and desktop; the grid's second column always shows upcoming bookings. Activated hosts are unchanged (pure render-order change, no data/logic touched).

    — Fixed

    • One-off WeekSlotGrid — past times were placeable; drag painted overlaps (3ea02b7, UX audit #5/#3). At 15:00 a host could paint 09:00 today and ship a link the invitee can never book (the read path drops past starts) — the earliest placeable start on today is now "now" (snapped up), respected by place / the per-day "+" / the hover ghost / drag, and the past region of today is dimmed. And a mouse/pen add-drag now range-fills by duration stride from the drag origin (origin + i·durationMin), so a 45-min duration lays a tidy back-to-back stack instead of overlapping proposals (12:00–12:45, 12:30–13:15, …) at any duration/speed.
    • One-off WeekSlotGrid — the at-cap state explains itself; dead erase-drag removed (69afa21, UX audit #8/#9). The 60-slot cap silently no-op'd clicks and hid the ghost with no explanation → the footer now shows "Maximum {max} créneaux atteint" (the charte's "a disabled control must show its reason"). The unreachable erase-by-drag branch (a "drag wipes a column" foot-gun with no undo) was removed — a drag now only ADDS; removal stays click-only on the chip.
    • Booking refunds return the platform fee + are idempotent (474b459, payments P2). refundBookingPayment issued a refund without refund_application_fee: true, so on a direct charge the invitee was fully refunded from the host's connected balance but Caledee kept its cut — a Free host was out the fee on every refund, and admin commissionCents under-reported vs Stripe. Now passes refund_application_fee: true (full refund → the platform cut returns to the host) plus an idempotency key refund-${payment.id}, so a double-cancel (manual + two-way-sync auto-cancel racing the same paid booking) can't issue two refunds.

    — Fixed — observability

    • Sentry now captures failures on the money-init, calendar-sync, and seat-ledger paths (d7b4c06, errors-audit P1). Several money/booking paths only console.error'd on failure — and because they're caught, instrumentation.ts's uncaught-only onRequestError never fired, so a Stripe outage or a revoked host token broke checkout/booking with zero Sentry signal. Added Sentry.captureException to the money-init routes (billing/{checkout,portal,start}, connect/stripe), the booking payment-setup catch + its rollback-cancel catch, the 8 external-calendar sync/delete catches in bookings.ts (at level:"warning" — a revoked host token shouldn't page, but a fleet-wide sync failure must be trendable), and the PollSeatPayment ledger-stamp .catch(() => {}) swallow. Purely additive (capture calls only), no behavior change.
  8. Version 0.38.0

    pre-launch audit wave · comp→conversion · MRR/churn truth · SSRF guard · custom-domain TLS unblocked · plan-parity · one-off reschedule

    An 11-audit pre-launch wave (api-webhook · plan-parity · security · wiring · payments · behavior · copy · i18n · redirects · errors · ground-truth) + 2 product scouts, its P0/P1 fixes, and the inter-session one-off/dashboard work that landed after v0.37.0. Promoted to prod 2026-07-24 as e37350e, now superseded by v0.39.0 / 140a897. No migration rides along. Gated by 438 web unit + integration green, tsc 0, turbo lint clean on every commit.

    — Added

    • Reschedule a booked one-off / single-use link — and it STAYS SPENT (#350, 2926844). The invitee can now MOVE their single-use / one-off booking exactly as they already cancel it (full Calendly parity), while no third party can ever book a fresh meeting on the spent link. Three token-scoped + singleUse-scoped bypasses of the isActive gate (validateAndPrepare, the slots API, the /r page) driven ONLY by rescheduleBooking's module-private reschedulingFrom opt — it never crosses an HTTP boundary, so it can't be forged. The reschedule tx never rewrites EventType.isActive, a third-party createBooking still 404s, and the Serializable single-use claim (untouched) still requires isActive:true. Also closed a pre-existing TOCTOU: the in-tx cancel became a status-guarded updateMany({where:{status:"active"}}), so two concurrent reschedules on one token can no longer each mint a booking (exactly one wins, the loser 409s).
    • Authenticated ops morning-report endpoint (69ffe66) — GET /api/ops/morning-report, closed by default (401 unless OPS_REPORT_TOKEN is set AND the Bearer matches, timing-safe). Returns only non-sensitive aggregates (db/redis health, worker heartbeat liveness, pausedActiveLinks, lapsedSubs24h, bookings24h, Sentry/Better Stack counts, build SHA) — never PII — so a cloud cron can carry the low-risk read token. Sections degrade to not_configured when a token isn't in prod env yet. Not yet armed (needs OPS_REPORT_TOKEN + the two read tokens on prod, then the cron pointed at the live endpoint).
    • Single prioritized dashboard nudge + the trial-ending conversion moment (ff1b284, UX audit #2 + product-scout). Three banners could stack and the calendar banner duplicated the onboarding checklist's own step; collapsed to ONE prioritized banner (trial-ending > calendar-connect > Calendly-import > fee-savings), never shown while the checklist is the focus. The trialEndingSoon helper shipped in billing.ts but nothing rendered it — a trialing host in their last ~3 days is the highest-conversion moment for the first paying cohort, so it becomes the top nudge → /settings/billing (wired with zero extra query).
    • State-aware first-run dashboard hero + honest "next in" glance (bfd086a, UX audit #1/#9). With zero event types the hero's single action is now "Create your first event type" (not "Share your link — bookings will appear here" over a link with nothing to book); checklist step 4 renamed to match what completes it. "prochain : 15 h 42" (a duration-until, misreadable as a clock time) is coarsened to a single unit.
    • unverifiedCount anomaly tile on /admin/finances (#355, d2cd2ca) — surfaces paid+active subscriptions that lack a stripeSubscriptionId (hand-edited grants, failed-webhook orphans, migration artifacts) so an operator reconciles them instead of silently trusting an inflated MRR. Shown only when > 0.

    — Changed

    • An expired comp no longer drops a customer who converted to a real subscription (#352, 9a6f9d3). effectivePlan reverted an expired comp to Free unconditionally, but the comp reaper only reaps expired comps with stripeSubscriptionId IS NULL — the two disagreed, so a comped account that later CONVERTED to a genuine Stripe sub was spared by the reaper yet computed as Free the day the comp lapsed (worst for a conversion that entered via a trial, since the webhook clears compedUntil only on status:"active"). Fixed by mirroring the reaper's guard byte-for-byte: an expired comp reverts to Free ONLY when !stripeSubscriptionId. Live-comp and pure-comp paths are byte-for-byte unchanged. See the new [[vault/domain/effective-plan-behavior]] truth table.
    • MRR & churn now require a real stripeSubscriptionId (#355, d2cd2ca). getFinancialSummary classified any paid+active row as revenue whenever compedAt was null, without checking for a Stripe sub — a hand-edited grant inflated mrrEur/payingCount (ARR ×12). Churn now also requires a sub id, so reaped comps (set to canceled with a null sub id) and never-paid trial cancellations no longer inflate churnPct.
    • Registered the 3 handled-but-unsubscribed Stripe webhook events (#353, 46e2e62) — charge.refunded on BOTH platform + Connect (booking charges are direct on the connected account, so a dashboard refund emits a Connect-scope event that applyChargeRefundToDb never saw → Payment stayed "paid", overstating GMV + commission), charge.dispute.created, customer.subscription.trial_will_end. ensureEndpoint now reconciles enabled_events on an already-created endpoint. A set-equality guard test locks the route's case labels ↔ the setup script's registered union both ways. Stage B (register the endpoints at Stripe + set STRIPE_WEBHOOK_SECRET_CONNECT) is still pending for go-live — test-mode is Claude-alone, live-mode needs Imad's per-op confirmation.

    — Fixed — security

    • SSRF guard on outbound webhook URLs + killed the response oracle (#356, 12078d1). A Team host's webhook subscription URL was HTTPS-only but had no private-range block, and the receiver's response body was echoed (≤280 bytes) into the host-readable delivery log — a same-host SSRF + response oracle against internal addresses (metadata, 10.x, localhost, or a public name RESOLVING to one). New shared lib/ssrf.ts: assertPublicHttpUrl (HTTPS-only, blocks IP-literal private/loopback/link-local/CGNAT/metadata ranges v4+v6, and resolves the hostname to defeat DNS rebinding) runs immediately before every delivery fetch; the create-time Zod schema gained a sync blockedHostSyntactic refine; a blocked target is a permanent rejection that disables the subscription with no network call; and the 4xx log now stores HTTP <status> only — never the receiver's body.

    — Fixed — plan-parity

    • secret_events: canSecret now reaches the UI and the gate is transition-aware (#354, 65d4b8b). secret_events was gated server-side but canSecret reached no control, so a Free host ticked "make secret," filled the whole form, and learned the truth from a hardcoded-English error at submit; worse, the update gate blocked on isSecret && !canUse, so a lapsed Pro couldn't save ANY edit of an already-secret event. Now canSecret is computed in both loaders and threaded to the create checkbox + edit header toggle (LockBadge "Pro", disables ENABLING only), and the server update gate is transition-aware (isSecret && !existing.isSecret && !canUse). secret_events removed from the plan-gate-parity NO_EDITOR_CONTROL exemption — the CI guard now PROVES the thread reaches the control.

    — Fixed — one-off

    • One-off conferencing gated by the organizer's real connections (#344, 6f47bd5). The one-off form hardcoded connections={{google:true,microsoft:true,zoom:true}}, so a host could pick Meet/Teams/Zoom with no such account — shipping a location label with no join link (silent degradation). Now gated at both layers: the UI dims the options the SELECTED organizer can't produce (resolved per candidate organizer for on-behalf), and createOneOffAction refuses on the RESOLVED organizer via getActive{Google,Microsoft,Zoom}Connection(ownerId) — the same oldest-per-provider account the write path mints the link on.

    — Fixed — infra

    • Custom-domain TLS cron is executable again (P0) (5435320). infra/scripts/sync-custom-domain-labels.sh was committed 100644 at v0.36.0 / origin/master / staging HEAD, so its */5 cron died Permission denied on every run and TLS auto-issuance worked for exactly one domain — the operator's own book.galaxee.fr, frozen in the Traefik override since Jun 13. Commit 0322833's message had recorded the exec-bit as "shipped + verified in prod," but its diff carried no 100644 => 100755 mode change — the fix never entered git, and git reset --hard on every deploy re-broke it. Fixed via git update-index --chmod=+x (so the next deploy lands it executable) + an immediate chmod on the VPS (manual run then succeeded). Blast radius verified first: exactly 1 CustomDomain in prod (the operator's), 0 customers. Custom-domain TLS is now genuinely live for the next paying subscriber — not just the operator. Content byte-for-byte unchanged; mode only.

    — Fixed — copy

    • Admin comp-expiry help no longer claims an unconditional revert (940bdd7, post-#352). The plan-override form's "Access reverts to free after this date." help text was false after #352 (a converted comp keeps its plan); reworded to carry the caveat the sibling toast/blurb already had. Admin-only, EN.
  9. Version 0.37.0

    marketing-site content waves · weekly Google-Calendar slot grid

    Tagged at 094619b, on top of the v0.36.0 truthfulness pass. A marketing-site content sequence (nav/footer IA + home waves 2-3 + money/persona pages) plus the one-off weekly slot grid. No migration. (This section reconciles CHANGELOG drift — v0.37.0 was tagged but never got a closeout, and the grid entry had been stranded in [Unreleased].)

    — Added

    • One-off slot picking is now a weekly Google-Calendar grid (51c24da, components/week-slot-grid.tsx, replaces calendar-slot-picker.tsx). 7 day columns × hour rows, week navigation with the selection persisting across weeks, a hover ghost sized to the chosen duration, per-day clear, a current-time line, and diagonal hatching for hours outside the organizer's availability (wired to the existing hostWeeklyAvailabilityAction). Built from frames extracted out of the source Calendly recording rather than from memory — which corrected two assumptions: the duration is a selector and slots SNAP to it (a drag paints N consecutive slots, it does not set a variable duration — a per-slot duration would have broken lib/one-off.ts's date_override + incrementMin model), and the hatching exists at all. Same PickedSlot contract, so the action and the engine are untouched.
    • Marketing site — nav + footer IA (71c570b) — one shared nav/footer config feeds three menus with no dead links.
    • Home wave 2 (e2a4261) — AI banner, use-case tiles, FAQ, and two truth corrections on what is actually live.
    • Money pages wave 3 (b755d65) — publish the affiliate void conditions; fix a latent Enterprise over-claim.

    — Changed

    • Pricing: Team seats are unlimited, billed per seat (1f39fe8) — dropped the unenforced "up to 50" cap.
    • Enterprise is "sur mesure" with no price floor; personas address practices, not just solos (094619b).
  10. Version 0.36.0

    group is a real kind (ADR-0025) · auth hardening · truthfulness pass · member-leave reconciliation

    Tagged retroactively at bcdd8d0, the exact SHA running in production — prod had drifted 17 commits past v0.35.0 with no named rollback point. Two additive migrations (event_type_kind_group_enum, event_type_kind_group_backfill).

    — Added

    • group is a first-class, IMMUTABLE EventTypeKind (ADR-0025). It had never been a kind — "group" was maxInvitees > 1 re-derived at nine sites, which mechanically produced a phantom type-union member, an importer that discarded the type, a plan gate keyed on a number, and a freely mutable kind whose switch deleted the event type's entire host pool. The kind is now chosen in the "+ Create" menu and frozen; the editor states it instead of offering it, and each kind leads with the options that define it (maxInvitees moved out of the Duration section's "+ more options", where setting it silently converted a 1:1). Migrations are split because Postgres cannot USE an enum value in the transaction that adds it; the backfill's kind = 'one_on_one' predicate is a hard poll-backbone guard (backbones legitimately carry capacity).
    • CI guards replacing agent findings — plan-gate parity (every canUse(plan, X) in the event-type actions must be re-checked on read or carry a documented exemption, and its can* prop must reach both loaders) and i18n key + placeholder parity across EN/FR/ES.

    — Fixed — security

    • /api/auth/* had no rate limit at all. The login throttle lived in a server action, while Auth.js mounts its own endpoints beside it: unbounded password spraying, plus an unthrottled magic-link endpoint that mailed any address on demand. Both closed, on a bucket shared with loginAction so the two doors can't be played against each other.
    • Password reset and change now bump sessionVersion. The mechanism existed and worked but was invoked in exactly one place, so the intuitive recovery move ("Forgot password") left a stolen session alive for its full 30-day life.
    • One-offs fenced out of the update + duplicate actions — the edit page notFound()s on them, but a forged POST reached the action and could re-open a spent single-use link.

    — Fixed — money & correctness

    • Any paid event type was un-pausable on downgrade. requiredFeatureForEventType returned a single first match with paid_events checked first; correct until paid_events was opened to all plans on 2026-06-05, after which the ordering silently became a mask. Now returns a Feature[] and the caller pauses on the first feature the plan lacks. The create gate and the downgrade-pause also disagreed about collective and group; both now derive from one rule.
    • Removing an org member reconciled nothing — the ex-member kept receiving round-robin bookings on their personal calendar and invitee PII as an attendee, on a seat the org had stopped paying for. Now "stop the flow, honour the stock": no new booking is routed to them, every existing one stands, zero calendar writes.
    • Duplicating a group on Free minted an incoherent clone (kind: "group" with maxInvitees: 1) — two independent clamps that had to agree and didn't.

    — Fixed — truthfulness (all live on prod)

    • The homepage sold three Team features on the €7 Pro card, contradicting our own pricing page; /vs/calendly claimed Calendly lacks polls three rows above a table saying it has them; Slack was badged "live" while SLACK_SIGNING_SECRET was unset in every environment; the privacy policy claimed encrypted 90-day backups over what is plain 14-day local gzip (a security claim in a legal document). /vs also under-sold our own Free tier — deposits are free on every plan since the 2026-06-05 pivot.

    — Fixed — infra

    • Custom-domain auto-TLS worked for exactly one domain: ours. sync-custom-domain-labels.sh was committed non-executable, so its 5-minute cron had failed ~9,987 consecutive times since Jun 12; the generated Traefik override froze on that date, carrying only our own host. A Pro subscriber would have verified their domain, seen a green tick, and served their booking page with a certificate warning. Fixed via the git file mode so a deploy can't revert it.
+ Show 34 older releases
  1. Version 0.35.0

    One-off meetings · create-on-behalf · "+ Create" menu · round-robin reschedule mode

    A team-scheduling + Calendly-UX wave on top of v0.34.0 (15 commits, three additive migrations: round_robin_reschedule_mode, event_type_one_off, one_off_holds). Driven partly by a teardown of real Calendly screen recordings (vault/domain/audits/calendly-ux-teardown-2026-07-02.md). Headlines:

    — Other changes

    • Create / manage on behalf of a team member (organizer selector). An org owner/admin picks the organizer when creating an event type — it's owned by that member (their booking page, availability, slug), shows in their listing, and the admin sees it in a new "Team" view of /event-types grouped by organizer (P3). The round-robin/collective editor now also shows each host's weekly availability inline (P4). Plan gates stay on the actor; the Free cap counts in the organizer's namespace.
    • One-off meeting ("réunion ponctuelle", teardown P1). A calendar-first flow to propose a hand-picked set of datetimes for a single meeting and share one link that closes on first booking — modeled as a hidden single-use event type (isOneOff) with availability = date_override rules, quota-exempt (works on Free at the cap), with an organizer selector (create on-behalf) and optional hold slots that block the proposed times on the host's other event types until booked. ADR-0024; help article /help/one-off-meetings.
    • "+ Create" menu (teardown Q3/P5). The event-types list "New" button becomes a dropdown that pre-selects the meeting type up front (one-on-one / group / round-robin / collective / réunion ponctuelle / availability poll → the right pre-configured editor via /new?kind=…), with correct Pro / Team badges matching the server gates.
    • Round-robin reschedule mode (same_host | redistribute) — on reschedule, keep the same host or re-run the rotation; also fixes a latent bug where a round-robin reschedule fell back to the owner instead of a pool host.
    • Calendly importer fidelity — map isSecret (was dropped), internalNote, locale; derive roundRobinIncludeOwner from the pool.
    • Hardening (7-agent review wave): the single-use deactivation now claims isActive atomically inside the booking transaction (kills a concurrent multi-slot double-book — applies to all single-use links); one-off holds get an availability query-guard + explicit cleanup + a daily reaper; cross-midnight slots no longer silently drop. tsc 0, lint 9/9, 121 integration + 338 unit green.
  2. Version 0.34.0

    Availability personalization · round-robin Calendly parity (organizer-not-host · union vs shared-frame)

    An availability + round-robin wave on top of v0.33.0 (6 commits, three additive migrations). Headlines:

    — Other changes

    • First day of the week (User.weekStartsOn — Monday / Sunday / Saturday). Reorders every weekly grid (availability editor, event-type weekday rules, booking-page month calendar) and redefines the "work week" quick-apply button (e.g. Sunday-start → Sun–Thu).
    • Public holidays by country — import a country's upcoming public holidays as availability days-off, one-click "add all" or one by one, from an offline 206-country dataset (date-holidays).
    • Round-robin — true Calendly parity: Organizer need not be a host — an "Include me as a host" toggle; off = the organizer schedules the rotation without taking any of its meetings. Availability is the UNION of the pool, not the organizer's schedule — a slot is offered if ≥1 host is free, computed from each host's OWN availability. The organizer's calendar no longer gates a team they're not on, a teammate's free slot is no longer hidden when the owner is busy, and a host is never assigned a slot outside their own hours. Two availability modes (roundRobinSharedHours) mirroring Calendly's "each host's own availability" vs "same hours for all hosts": the latter makes the event's own (custom-able) schedule a shared FRAME — a slot shows if it's inside those hours AND ≥1 host has no calendar conflict. Default host-pick strategy → priority (mirrors Calendly's "Maximize for availability"); existing rows keep their stored value.
    • Google OAuth: dropped the unused userinfo.profile scope (minimum scopes, verification-friendly).
    • Docs & learning loop: /help/teams rewritten (EN/FR/ES) for the two round-robin modes; Calendly-parity + a durable behavior note updated; behavior-audit gains a "multi-host slot-source" dimension + a new gotcha so this class is caught automatically next time. Gated by 337 web unit + 106 integration green (CI), tsc 0 (web), web lint 0 errors, turbo lint typecheck 15/15; staging deployed + healthy on every commit. Three additive migrations ride along (applied + verified on caledee_staging):
    • 20260618133911_user_week_starts_on — User.weekStartsOn Int @default(1).
    • 20260620102241_round_robin_include_owner — EventType.roundRobinIncludeOwner Boolean @default(true).
    • 20260621141416_round_robin_shared_hours — EventType.roundRobinSharedHours Boolean @default(false) + RR default strategy → priority (only new rows; existing rows keep their value).
  3. Version 0.33.0

    Availability & event-type editor overhaul · slots-capacity badge · 4-star round-robin priority

    An availability + event-type-editor wave on top of v0.32.0 (12 commits, one additive migration). Headline: per-event custom hours (edit a schedule's hours → it auto-becomes this-event-only, with an option to name & save it as reusable), reusable schedules capped at 5, a "copy to Mon–Fri / every day" affordance so hours aren't re-typed, and a per-event-type "available slots" capacity badge on the list (rolling-window-accurate, duration+buffer aware, with a styled hover/focus tooltip) — plus typable +/- steppers and a Calendly-style 4-star round-robin priority picker. Trailing v0.32.0 follow-ups ride along (transactional-email frame fix, team-import member notify, Zoom marked live). Gated by 333 web unit + integration (CI) green, tsc 0 (web), web lint 0 errors; staging deployed + healthy on every commit. Staging only — prod promotion (master fast-forward + the manual GitLab gate) is Imad's separate gated step. One additive migration rides along (applied + verified on caledee_staging): 20260617145832_availability_inline_event_type — adds nullable AvailabilitySchedule.inlineEventTypeId (@unique; NULLs don't conflict, so existing rows are untouched). When set, the schedule is a hidden, single-event "custom hours" schedule (never listed, excluded from the 5-schedule cap, cloned on duplicate, dropped when the event type switches back to a reusable schedule).

    — Added

    • Per-event-type custom hours (b2d8c6c) — in an event type's Availability section, the picker shows reusable schedules (the host's default auto-selected; no more duplicate "Default schedule" line) and the selected schedule's hours editable inline. The moment the host changes anything, the hours become custom to this event type (saved as a hidden inline AvailabilitySchedule), with an optional "name it & save as a reusable schedule" step. The grid re-derives from the selected schedule on every switch (no stale preview). create/update/duplicate/switch-away lifecycle handled in actions.ts. i18n EN/FR/ES.
    • "Available slots" capacity badge on the event-type list (c21f966, 3f90d39, 5217fbf, 1668ec7) — left of the "30d bookings" number, a per-event-type count of bookings that still fit in the next 62 days. CAPACITY semantics: stepped by duration + buffers (non-overlapping), not the booking increment — a 45-min meeting on a 15-min increment counts how many fit, not the overlapping start options the public page offers. Reflects the event type's rolling window (changing 60→40 days lowers it); 0 shown in red. Reuses the #292 engine via countUpcomingAvailability (hasUpcomingAvailability now wraps it). The public booking page is unchanged.
    • "Copy to Mon–Fri / every day" in the weekly-hours editor (510e013, b2d8c6c) — copy one day's hours to the work week or all seven days, on the Availability page and the event-type custom editor.
    • Calendly-style 4-star round-robin priority picker (15e9ecc) — the round-robin "By priority" strategy was correctly wired at booking time but only editable as an obscure 0-100 number; now a 4-star picker per pool member (filled stars = level, click to set; mid level by default). New reusable StarRating primitive. Backend selection + schema unchanged. i18n EN/FR/ES.
    • Feature-architecture map corpus (c21f966) — vault/domain/architecture/ holds per-feature cross-layer "wiring maps" (surface→loader→lib→engine→DB with file:line anchors); first map: event-types & availability. New architecture-cartographer agent owns it (indexed by agent-keeper).

    — Changed / Fixed

    • Typable +/- steppers (b2d8c6c) — the editor Stepper (rolling-window days, daily limit, group size) value is now a text field (commit on blur/Enter), not only the +/- buttons.
    • Styled tooltip on the list stat numbers (37a231c, a35f8ee) — the slots + 30d numbers used the browser's native title (silent/slow); replaced with a styled hover/focus bubble (same look as the shared InfoHint), via a shared StatBadge. The row Card's coloured accent moved from a left-border + overflow-hidden (which clipped the bubble) to an inset box-shadow.
    • Cap reusable schedules at 5 (510e013) — MAX_SCHEDULES, enforced server-side in both create paths; the "New schedule" button disables with a notice at the cap.
    • Transactional-email frame fix (7e36a6e) — a long custom-question label with white-space:nowrap squished the value columns and char-wrapped URLs ("weird frame"); fixed centrally in emails/_styles.ts (tableLayout:fixed, bounded label width, overflow-wrap) across all 7 templates.
    • Team-import member consent notify + Zoom marked live (da31376) — a matched member added via a team import gets an in-app notification ("you were added to {org} — you can leave"); the marketing integrations page marks Zoom live (env confirmed on prod). New notification kind org.added-via-import.

    — Docs

    • Post-v0.32.0 audit-wave follow-ups (6036707) — deferred P1/P2 fixes that missed the v0.32.0 tag.
  4. Version 0.32.0

    Calendly migration Phase 2 (team import) · invitee-chosen location

    The Calendly → Caledee migration grows up: one owner/admin Calendly token now migrates the whole organization in a single click (Phase 2, team), and a Calendly-parity booking feature — invitee-chosen location — ships alongside (with the migration-fidelity fix it exposed). On top of v0.31.0 (12 commits, two additive migrations). Gated by 333 web unit + 42 core + 87 integration tests (typecheck 0 on web/core/db/ui — the @calee/mobile Expo gap is local-only and CI-excluded; web lint 0 — the pre-existing FieldError/logger console + <img> warnings are allowed); the web build is green. Staging only — prod promotion (master fast-forward + the manual GitLab gate) is Imad's separate gated step. (ADR-0023.) Two additive migrations ride along (both already applied + verified present on caledee_staging): 20260614163230_event_type_invitee_location_choice (adds EventType.letInviteeChooseLocation bool, default false) and 20260614173706_org_import_source (adds nullable Organization.importSource).

    — Added

    • Calendly migration Phase 2 — TEAM import (43148bd) — one owner/admin Calendly token migrates the whole org in one click. lib/calendly/team.ts runCalendlyTeamImport reads the Calendly organization + memberships via the owner token (a single grant reads every member — verified live, read-only, against a real Team account), then per member: matches a Caledee User by email → creates a Membership + runs importOneUser({ skipPooledEventTypes }); no match → createInvitation (no shell user — the invite flow only creates a User at signup). Round-robin / collective event types are imported ONCE at the org level (owned by the importer, organizationId set) with EventTypeHost pool rows for accounted hosts; un-accounted hosts join on an idempotent re-run after they accept. Wired end-to-end: ImportJobPayload.mode:"team" + organizationId → a dedicated worker branch → startCalendlyTeamImportAction (Team-plan gated, resolves/creates the importer's org) → the /settings/import wizard auto-detects a team in preview ("Import the whole team" vs "Just my account"). i18n EN/FR/ES. (Refactor: importOneUser extracted from runCalendlyImport; the solo path is unchanged.)
    • Member-import dashboard nudge after a team migration (8d0afc8) — a team import stamps Organization.importSource = "calendly", so a non-owner member who joins a migrated org (the owner token couldn't pull their data until they had an account) now sees a "Coming from Calendly? Import your event types" dashboard card → self-import via /settings/import with their own Calendly. Idempotent with an owner re-run (same importExternalId), auto-hides once imported, dismissible. i18n EN/FR/ES.
    • Invitee-chosen location (9d35f54) — EventType.letInviteeChooseLocation: the host keeps a single default location (unchanged) and can offer several location options that the invitee picks from at booking time (default OFF = identical behavior). Editor location section gains a toggle + add/remove option rows (shared LocationKindField, max 5 extras, deduped); the booking page builds localized option labels server-side and shows a radio picker (the typed-location field follows the chosen option's kind). createBooking / resolveLocation validate the chosen locationIndex (in-range; missing/out-of-range → 400; default = first row) across /api/bookings + /api/v2/bookings. The chosen location persists on Booking.locationKind/Value and flows to emails / calendar / confirmation via the existing plumbing. Ungated (competitive). i18n EN/FR/ES.

    — Fixed

    • Migration fidelity: multi-location Calendly event types no longer silently lose their extra options (9d35f54) — an imported event type already stored its N Location rows, but the booking page only ever honored locations[0] and the editor collapsed to one on save, so a multi-location Calendly account quietly lost every option past the first. The importer now flips letInviteeChooseLocation ON when a Calendly event type has ≥2 locations, so all imported options are offered to the invitee.
    • Post-v0.31.0 audit-wave follow-ups (f05d67b, 55f18ea) — a batch of security / OAuth / wiring / copy fixes that missed the v0.31.0 tag, plus the v0.31.0 deferred-followups note + the ClickUp REST sync runbook.

    — Docs

    • User help: "Switching from Calendly" (b3b2d6d) — a new /help/import-from-calendly article (trilingual) walking a host (solo or team owner) through the one-click migration, plus an event-types location note for the invitee-chosen-location feature.
    • Calendly migration operations guide updated (98fbfd7, bdb1346, 116649f) — vault/domain/calendly-migration-map.md §4 (team) + docs/operations/calendly-migration.md marked BUILT, plus the live read-only team-migration exploration write-up.
  5. Version 0.31.0

    slot-cache + booking/marketing read-path hygiene · custom-domain routing forms · MCP directory prep · help screenshots

    A perf-hygiene + platform-prep batch on the staging trunk, on top of v0.30.5 (15 commits, no migrations). Headline: a two-layer slot-computation cache (version-keyed Redis, behind a flag) plus two booking/marketing read-path optimizations — with an honest empirical note that none of them moved the booking-page throughput ceiling. Plus custom domains now serve routing forms, the MCP directory prerequisites (tool annotations + DCR tests), unified location pickers, two seat-payment P2 fixes, the Outlook add-in manifest finalized (code-complete), and a real screenshot in every help article. Gated by 333 web unit + 78 integration tests (typecheck 0, web lint 0 — the pre-existing FieldError/logger console + <img> warnings are allowed); the web build is green. Staging only — prod promotion (master fast-forward + the manual GitLab gate) is Imad's separate gated step.

    — Performance

    • Slot-computation cache (#312, behind SLOT_CACHE_ENABLED, default OFF — ON on staging for dogfood) for the public slots API (/api/event-types/[id]/slots + /api/v2/availability). lib/slot-cache.ts getCachedBookableSlots: Redis, version-keyed (it embeds eventType.updatedAt + the host's AvailabilitySchedule.updatedAt + ConnectedCalendar.lastInvalidatedAt/updatedAt + Booking.updatedAt, so a Google push-webhook / availability edit / new booking auto-invalidates the key — in lock-step with the existing free-busy cache), a 30 s TTL backstop, single-flight to collapse a thundering herd into one recompute, and a safe fallback to direct compute on any Redis error. The booking write path stays uncached, so short staleness can't double-book (submit re-runs computeSlots + the conflict guard). (ADR-0022; design in vault/domain/slot-caching-design.md.)
    • Marketing kill-switch read off the hot path — the (marketing) layout read the #279 FeatureFlag kill-switch on every render (cookie-i18n forces dynamic SSR, so the marketing site is not static); that single per-request DB read is now unstable_cached (tag marketing-flags, 5-min TTL, revalidateTag'd by the admin toggle). Fail-open preserved.
    • Booking + reschedule pages load only the schedule timezone (optimization B) — the pages called loadSchedulingContext (3–5 queries + holiday compute) purely to read scheduleTimeZone; a new getBookingPageTimeZone does one column-select with the identical pinned→default→oldest fallback and the same null→notFound. Parity-locked by test/integration/scheduling-timezone.test.ts (helper ≡ loader across every branch).
    • ⚠️ Honest empirical note (staging A/B): none of the above moved the ~22 req/s booking-page ceiling — that's the ~63 ms RSC render itself (the page never calls computeSlots), not the queries these changes touch. They're clean hygiene whose payoff scales with computeSlots cost + concurrency (the slot cache) and removes a Postgres read from the marketing hot path; the page-ceiling lever is profiling/cutting the render, deferred. (vault/infra/capacity-and-load.md.)

    — Added

    • Custom domains now serve routing forms too (#313) — a host with a verified book.acme.com can share book.acme.com/f/{form}. The form, its post-submit redirects (thank-you, the off-domain interstitial), and routing-to-an-event-type all stay on the custom domain (middleware /f/ rewrite via the pure lib/custom-domain-rewrite.ts, single-segment public path handled across the submit→done/interstitial chain, buildFormUrl); it reverts to caledee.com/f/{slug}/{form} when the domain is unverified/disconnected. The /links page now shows the custom-domain form URL.
    • Outlook add-in manifest finalized (#269) — real GUID, canonical host, bundled icons; the add-in is now code-complete. (Hosting the taskpane + the AppSource submission remain, gated.)

    — Changed

    • MCP tools now carry annotations (#304) — every one of the 13 tools declares readOnlyHint / destructiveHint / idempotentHint / openWorldHint (a required, otherwise-rejected field for the Claude Connectors + ChatGPT Apps directories — a directory prerequisite). The MCP directory-submission dossier (vault/domain/mcp-directory-submission.md) was refreshed (#302); only the prod reviewer account + Imad's account/submission steps remain.
    • One shared location picker (#309) — the event-type editor, the new-poll form, and poll finalize now use a single components/location-kind-field.tsx instead of three near-duplicate pickers (the event-type payload is byte-identical, verified).

    — Fixed

    • Seat-payment P2 residuals (#308a) — startSeatPayment now guards a deleted/paused event type, and the paid-poll auto-finalize notification no longer says "0 booking(s)".
    • DCR / scope-gating test coverage (#303) — /oauth/register + .well-known route coverage and the Free-plan write-scope strip are now pinned (deferred from the v0.30.0 AI-native wave).

    — Docs

    • A real captured screenshot in every help-center article (#283) — a shared HelpFigure component plus one real screenshot embedded in all 12 /help articles (driven by scripts/shot.mjs against a seeded Team demo account; EN UI shared across locales with a localised alt). Regen procedure in apps/web/public/screenshots/help/README.md.
  6. Version 0.30.5

    custom-domain link consistency · VPS hardening

    — Fixed

    • Every public link now honors the host's verified custom domain (book.acme.com), or correctly stays on caledee.com when it should. On a custom domain the profile is served at the ROOT, so a /u/{slug} link 404s there — that bit the public event page's back-to-host link. Swept + fixed: the event page (back-link, the URL shown under the avatar, the "schedule a follow-up" href), the event-types list View, the short-link presets (profile + each event type a host shortens/QRs — so the QR encodes the custom target too), and the /team page (each member's event link → that member's domain). Emails were already custom-domain-aware (book-another, waitlist). Correctly NOT moved (by design): the caledee.com/s/{code} shortener service and token-based reschedule/cancel/reconfirm links. (Routing forms /f/ were excluded here too — now made custom-domain-aware in [Unreleased], #313.)
    • Double description on the public event page — the headline (h1) showed the description's first sentence AND the body repeated the full description. The h1 is now the event name; the description shows once.

    — Infra (ops, not app code)

    • VPS cloud firewall (caledee-vps, deny-by-default, allow 22/80/443 + ICMP) — the prod VPS had none before. See vault/infra/firewall.md.
    • n8n removed from the shared VPS (archived + snapshot; Traefik untouched) → the box is Calee-focused. See vault/infra/cohabitation-n8n.md.
  7. Version 0.30.4

    custom domains GO LIVE (Pro+) auto-TLS

    Custom domains ship for real: a Pro+ host points a subdomain (CNAME → caledee.com) at us, verifies a TXT record, and HTTPS turns on automatically within minutes — no infra ticket, no manual cert. Validated end-to-end in prod on book.galaxee.fr (valid Let's Encrypt cert auto-issued, serves the host's /u/{slug} booking page).

    — Added

    • On-demand TLS — Option A-bis (infra/scripts/sync-custom-domain-labels.sh + cron */5). Traefik v3 won't auto-issue a cert for the HostRegexp(.+) catch-all, and the SHARED root-traefik has no file provider (Option A) without touching Mehdi's n8n. A-bis stays 100% inside Calee: it feeds VERIFIED domains into the caledee-custom router as tls.domains DOCKER LABELS on caledee-web-prod, so Traefik's Docker provider issues an LE cert per domain (TLS-ALPN-01). Verified via docker compose config that labels MERGE additively (the caledee.com/.fr routers survive). The override is gitignored + regenerated on the VPS so the deploy's git reset --hard never wipes the live domain list; deploy:prod always merges it. Gated to verified rows only (anti-abuse on LE limits).
    • "HTTPS turns on in a few minutes" note in Settings → Branding → Custom domain (EN/FR/ES) so hosts expect the short Let's Encrypt delay and aren't alarmed by a transient cert warning.

    — Changed

    • Pricing: custom domain flipped off "soon" → live (Pro/Team/Enterprise) on the pricing table, the /vs/* comparison pages, and llms.txt — only after the real E2E validation, per the runbook.
    • Internal docs: tutorial-keeper now owns help-center screenshots; brand-assets/ surfaced in KNOWLEDGE.md; Zoom Marketplace submission dossier added; 800×800 listing icon generated.
  8. Version 0.30.3

    hotfix: worker crash-loop (email-jobs "@/" imports)

    A worker hotfix on top of v0.30.2 (which never reached prod — its staging deploy failed at the worker step, so prod stayed on v0.30.1). The v0.30.2 custom-domain commits left four @/-alias imports in lib/email-jobs.tsx, a file in the BullMQ worker's graph (tsx scripts/worker.ts) — and tsx does not resolve the @/ tsconfig alias at runtime. The static @/lib/public-url import crash-looped the worker at startup (ERR_MODULE_NOT_FOUND: Cannot find package '@/lib'); three await import("@/emails/waitlist-*") would have thrown when a waitlist email was sent. next build + typecheck + unit tests all passed (the web bundler resolves @/), so it only surfaced at deploy.

    — Fixed

    • Worker crash-loop on the email-jobs @/ alias — lib/email-jobs.tsx now imports ./public-url and ../emails/waitlist-* (relative), never @/.... A new lib/worker-import-boundary.test.ts walks the worker's import graph (following static AND dynamic imports) and fails if any reached file uses a runtime @/ import — so a worker-breaking import is caught in CI, not at the deploy step. The client-server-boundary test gained the same dynamic-import traversal.
  9. Version 0.30.2

    hotfix: /settings crash (Prisma in the browser) · custom-domain links honor the verified domain

    A patch release on top of v0.30.1 (5 commits, no migrations). Headline: a prod-broken /settings — every authenticated user's Settings page crashed at hydration — is fixed, plus the custom-domain booking links now follow the verified domain everywhere.

    — Fixed

    • /settings crashed for every authed user (prod + staging) — branding-form.tsx and settings-forms.tsx ("use client") imported hostInitials from @/lib/branding, which since v0.30.1 imports prisma (@calee/db) at module level → the browser bundle pulled in Prisma and /settings/profile + /settings/branding threw at hydration (server rendered the page, then the error boundary swapped in). next build did not fail (@calee/db has no server-only poison) and Sentry was empty (client error, not server-captured), so it shipped to both envs. Fix: the pure helpers (hostInitials, NEUTRAL_BRANDING, HostBranding) move to lib/branding-display.ts (zero server imports); branding.ts re-exports them for server callers; the two client components import from the pure module. A new lib/client-server-boundary.test.ts walks every "use client" file's runtime import graph and fails if any reaches @calee/db — so this whole class is now caught in CI (verified: it fails on the bad import, passes on the fix).

    — Changed

    • Custom-domain booking links honor the verified domain everywhere (#310) — the event-type editor + embed, the event-types list, the OG share image, and the booking links inside emails now render https://book.acme.com/intro instead of the hardcoded caledee.com/u/..., computed-live via lib/public-url.ts (and reverting to the default links the instant the domain is disconnected). Profile event-type links are now absolute, fixing a /u/{slug}/u/{slug} double-prefix 404 on custom hosts.
    • On-demand TLS for custom domains (Option A) — infra/scripts/sync-custom-domain-certs.sh + runbook feed the verified domains into Traefik's file provider so each gets a Let's Encrypt cert; issuance is gated to verified rows (anti-abuse).
  10. Version 0.30.1

    Microsoft connect fix · branding lapse-leak · pay-to-confirm honesty · OAuth/DCR hardening · custom-domain links (foundation)

    A patch release on top of v0.30.0 (14 commits, no migrations). Headline: a prod-broken Microsoft 365 connect is fixed, plus a batch of audit-driven hardening from the 2026-06-12 wave.

    — Fixed

    • Microsoft 365 connect was broken in prod — AADSTS90023 Unsupported 'prompt' value: we sent prompt="select_account consent", but Azure AD accepts only ONE value (that's a Google behavior). Now select_account (refresh token comes from the offline_access scope). A new lib/oauth-authorize-url.test.ts pins each provider's authorize-URL params against that IdP's accepted values so this class can't ship again.
    • Branding lapse-leak (plan-parity P1) — a host who set a logo + hid the "Powered by Caledee" footer while Pro, then lapsed to Free, kept both on every public page forever. Branding is now masked at RENDER against the live effective plan (computed-live, reverts on lapse + restores on re-upgrade); accent_color (free) survives.
    • Pay-to-confirm /seat honesty (behavior P0) — the page no longer claims "confirmation on its way" when a paid seat's booking-attach failed; shows an honest "finalizing" state keyed on the real inviteeId.
    • Auto-finalize without a deadline silently never fired — now rejected at the boundary (a poll set to "auto" must have an "Auto-close after").
    • OAuth DCR global cap (oauth/security P1) — anonymous Dynamic Client Registration now has a global ceiling (503 + Retry-After) so rotating IPs can't grow the table unbounded between reaper runs.
    • Custom-domain verify hardening — a transient DNS/DB hiccup returns a friendly retry instead of a 500.

    — Added

    • Custom-domain booking links (foundation) — lib/public-url.ts central helper: a verified custom domain is now reflected in copied booking links (https://book.acme.com/{eventSlug}), computed-live (reverts instantly on disconnect). Threaded into the event-type editor + dashboard; the full link sweep + emails + on-demand-TLS infra are tracked (the feature stays "soon" until E2E).
    • Google API Services / Limited Use affirmation added to /privacy (EN/FR/ES) — de-risks the in-flight Google OAuth verification.
    • Custom-domain apex rejection + one-click-copy DNS records + an unmissable registrar host-field warning (the OVH-doubling footgun).
    • Zoom granular OAuth scopes (meeting:write:meeting / meeting:delete:meeting / user:read:user) — Zoom dropped the classic scopes.

    — Changed

    • /about security trim — dropped attacker-useful specifics (component versions, the exact auth/session mechanism, CI pipeline internals, proxy/orchestration) while keeping the intentional EU-sovereignty signals (Paris / European VPS / OVH France).
    • HubSpot app description corrected (contacts-only; the meeting-logging scope was rejected) + a paste-ready marketplace-listing dossier.
    • wiring-audit gained a "capability consumer-coverage" dimension (Dim 17) so a half-wired capability (verified but ignored by N surfaces) is caught automatically.
  11. Version 0.30.0

    Polls v3 (the poll IS an event type, paid pay-to-confirm) · pricing Scénario B · booking-page P0 · AI-native (DCR/MCP open) · marketing truth · editor de-monolithed

    The execution of the "become the leader" strategy, on top of v0.29.0 — the largest single lot to date (42 commits, 4 additive migrations). Gated by 414 tests (42 core + 306 web + 66 integration); typecheck 0, lint clean. A dedicated payments-audit ran on the pay-to-confirm money path (verdict NO-GO → all P0/P1 fixed same-day) and a code-review confirmed the editor extraction is behavior-identical. Staging only — prod promotion is Imad's separate gated step. ⚠️ Pay-to-confirm ships dormant on prod (Stripe is unconfigured there until the SIRET go-live) — no real money flows until then, exactly like SMS.

    — Added

    • Polls v3 — a poll is now a first-class event type. Every poll owns an invisible kind=poll backbone event type (minted at create, lazily at finalize for legacy polls), so a poll carries the SAME configurable settings as an event type: location (incl. Google Meet / Teams / Zoom auto-links), workflows, duration. Excluded from every quota, list, picker and pause path; never counts against the Free event-type limit.
    • Polls v3 — multi-slot finalize with capacity. When a poll has a per-slot capacity, finalizing fans voters across multiple kept slots via a greedy assignment (score-ranked yes → if-need-be → unplaced), with a per-slot assignment preview before you commit. Unplaceable voters become CRM leads by default, or you can opt to invite them anyway.
    • Polls v3 — auto-finalize at the deadline (Pro, poll_auto_finalize) — a poll can finalize itself at its deadline onto the best-scoring slot, guarded by an optional quorum (fail-closed + host notification if unmet). Runs in the existing autoclose cron.
    • Polls v3 — paid polls with pay-to-confirm (48 h). A paid poll doesn't book anyone at finalize: each assigned voter gets a 48-hour seat + a payment link (/seat/{token}, the same Stripe Elements as the booking flow, charged on the host's Connect account with the 3 %/0 % platform fee). The real booking is created/attached on payment (webhook, atomic claim); unpaid seats expire and the next candidate is promoted; the poll flips confirming → confirmed when every seat settles. New PollSeat + PollSeatPayment models keep seat money in a separate ledger — the core Payment model is untouched. Surfaced in /admin/finances (seat GMV + a "paid, not booked" attention counter). (ADR-0021.)
    • Polls v3 — host can vote on their own poll ("My availability" row on the detail grid) and move a slot without losing its votes (id-first reconciliation), with "time changed" markers + a poll.updated email to confirmed voters.
    • Pricing — Scénario B, the generous Free that creates the switch: Free now gets 3 event types (was 1), 2 calendars (was 1), an accent colour, and 1 workflow; paid plans unlock unlimited + 0 % payment fees + footer removal + custom domain. Payments + polls remain on Free (3 % fee).
    • AI-native (wave 1): OAuth Dynamic Client Registration (RFC 7591, PKCE-only public clients) + .well-known discovery (RFC 8414/9728), and the MCP server is now open to all plans (read on Free/Pro, write Pro+; API/Zapier stay Team). A daily DCR reaper purges never-authorized public clients. New /ai landing page.
    • Marketing: /rgpd (GDPR technical-facts page), /vs/doodle (poll-first comparison), /ai — all truthful against shipped features.
    • Booking: all participants of a group/poll booking now show in the dashboard booking details (was capped at one); pick a Meet/Teams/Zoom/written location at poll-finalize time.

    — Changed

    • Editor de-monolithed — event-type-form.tsx went from 2461 → ~1280 lines; its 11 sections (basics, kind, duration, location, questions, payment, schedule, calendar, enhancements, visibility, confirmation) now live in focused event-types/sections/* files. Mechanical, behavior-identical (code-review verified); accordion wrappers + sticky UI state stay in the parent (AccordionSection unmounts closed children — gotcha logged).
    • Booking page P0 (trust + perceived speed): removed the 24 h hourCycle:"h23" confirmation bug + hardcoded-FR strings seen by EN/ES invitees; prominent time-zone; skeleton + loading.tsx; no slot-flash on month change.
    • Dashboard: the payment fee figure shows the real 3 % (was a stale 0.45 %) + a fee-savings upgrade nudge visible to Free hosts.

    — Fixed

    • payments-audit (pay-to-confirm): closed a P0 double-charge/orphan-PI window (reuse a succeeded/processing PI, never re-mint; idempotency key; metadata.seatId webhook fallback; Sentry on orphan PIs), plus the foreign-event-type side-door, the simultaneous-payer attach race (retry + host alert), Stripe refund reflection into the seat ledger, and seat revenue visibility in finances. 3 integration tests pin the money invariants.
    • oauth-audit P1: a refresh-token replay now revokes the whole token family; consent screen hardened.
    • Stripe webhook handler failures now route to Sentry instead of being swallowed.
    • plan-parity: gate group events + SMS reminders; removed stale plan claims.
  12. Version 0.29.0

    Onboarding wizard · poll-vote default flip · request-a-slot leads · i18n/marketing/admin sweep

    A large 2026-06-09/10 batch on the staging trunk, on top of v0.28.0: a full multi-step onboarding wizard (now with skippable steps), the poll-vote email-confirmation default flipped to opt-in (instant votes), a "request a slot" lead path for fully-booked event types (ADR-0020), poll voters become Contacts, an admin marketing kill-switch, and a broad i18n / marketing-truthfulness / event-types pass. Gated by 393 tests (42 core + 293 web + 58 integration); typecheck 0, lint clean. Staging only — prod promotion (master fast-forward + the manual GitLab gate) is Imad's separate gated step.

    — Added

    • Multi-step onboarding wizard (/onboarding) — replaces the single slug+timezone screen with a guided ?step= flow: profile (name → auto-derived handle + booking link + timezone + optional avatar) → connect calendar (Google/Microsoft, skippable; the OAuth flow now returns to the wizard) → quick-edit the seeded event → set weekly availability → done (with your public booking link). The seed (default schedule/event/workflow) is idempotent; onboardedAt is only set on finish. Falls back to the existing dashboard calendar-connect nudge if you skip calendar.
    • Onboarding: steps 3 (event) and 4 (availability) are now skippable, and the dashboard onboarding checklist honors the skip — a skipped step no longer shows as "done". New User.onboardingSkipped records which steps the host chose to skip.
    • Booking: fully-booked → "request a slot" lead (default) — when an active event type has no bookable slot, the booking page now offers to request a slot (captures a LeadCapture lead the host is notified about) instead of dead-ending. The host can opt into the previous waitlist behavior per event type. The captured lead carries LeadCapture.source so the CRM can tell it apart. (ADR-0020.)
    • Contacts: a "Requested a slot" tag in the CRM — a lead captured from the request-a-slot flow surfaces a distinct badge (sourced from LeadCapture.source) so the host can triage demand for sold-out event types (#284).
    • Polls: invite roster autocompletes from existing contacts — the Pro invite-roster form completes from your past booking invitees, captured leads, and poll voters; picking a contact appends its email (deduped). You can still paste addresses directly.
    • Poll voters are now Contacts — anyone who voted on your poll appears in the Contacts CRM (and GET /api/v2/contacts) alongside booking invitees and captured leads; an unbooked voter shows as a lead-level contact, a voter who also booked stays a client.
    • Polls: link an event type to a confirmed poll inline — once a poll is confirmed you can attach an event type directly from the poll page to turn the winning slot into a group booking (replaces a dead "Modifier" link).
    • Links page: filter tabs by kind — the /links page gains all / profile / event types / routing forms / custom tabs with per-kind counts (client-side, instant).
    • Event types: a "No availability" tag when an active event type is currently unbookable (hasUpcomingAvailability) — so a host can spot a live-but-unbookable type at a glance instead of from a failed booking (#292).
    • SMS: the reminder SMS now carries a shortened reschedule link — a non-public ShortLink kind (reschedule) keeps the per-booking reschedule URL short for SMS without polluting /links (the /links query filters to kind="custom").
    • Help center: a dedicated help namespace + a new "Managed workflows" article — the help articles now have their own i18n namespace (no more borrowing), and the org-deployed-workflows feature gets its own how-to.
    • Marketing: the booking-page welcome video is now sold on /features (it shipped on every plan but was previously unadvertised).
    • Admin: marketing-page online/offline manager (/admin/marketing) — an operator kill-switch that lists every public marketing surface with a one-click Online/Offline toggle. Backed by FeatureFlag rows (marketing_page:<slug>), enforced in the marketing layout (offline → 404 the whole section). The homepage is intentionally not toggleable. Indexed in the admin system ops-index.

    — Changed

    • Polls: vote email-confirmation is now OPT-IN (host toggle requireVoteConfirmation, default off) — behavior change. A vote now counts instantly by default (no email round-trip), matching Doodle/Calendly defaults; previously every vote required an emailed confirmation link to count. The host can opt into the old click-to-confirm loop per poll. Per-slot capacity counts confirmed votes either way.
    • i18n: the /docs chrome is now fully French/Spanish — the docs-site navigation, headings, and chrome are translated (not just captioned "published in English"), via a new docs namespace. Article bodies stay English-canonical.

    — Fixed

    • i18n: calendar event title/description + the in-app "new booking" notification are now localized to the host's locale — French hosts saw mixed copy like "réunion de 30 mn with personne"; the connective glue ("with", "Invitee:", "Notes:", reschedule/cancel labels, "New booking") was hardcoded English. Now a worker-safe locale→words map (no message-loader, since this path also runs in the BullMQ worker). The event name + custom-question labels stay exactly as the host wrote them.
    • i18n: the API-tokens Team upsell is now localized (was hardcoded English) in Settings → Developers.
    • Event type: the slug now auto-derives from a template-prefilled name on mount — creating an event type from a template prefilled the name but left the (required) slug empty, and the auto-derive only fired on the name's onChange, so a user who didn't retype the name couldn't save (the "type a space then delete it" workaround). No effect on edit (custom slug preserved) or a fresh-new event.
    • Onboarding action errors are now localized (were English).
    • Marketing truthfulness — dropped the Apple/iCloud calendar-sync claim (not shipped), and moved the Zoom integration from "live" to "soon".
    • Marketing mobile nav: the drawer is now portaled to <body> — it was rendering inside the backdrop-blur header, so it visually "opened in the header" / was clipped. The drawer now escapes the header stacking context.
    • Marketing kill-switch fired only on the default locale — the x-pathname header carries the un-stripped /fr|/es prefix, so the slug lookup read "fr" and returned null; now strips the locale segment first (c5dc69f).
    • Toggling requireVoteConfirmation on an existing poll was silently lost — the create path wrote it but the update path didn't; both write it now (c5dc69f).
    • Onboarding availability now validates end > start live (inline error + Finish disabled) instead of erroring on the wizard's terminal step (c5dc69f).

    — Infrastructure

    • deploy:staging OOM fixed — the Next.js production build OOM-killed on the VPS; the build now runs with NODE_OPTIONS=--max-old-space-size=4096 (4 GB heap).
    • CI lint red fixed — removed an eslint-disable for a rule the config doesn't define (turbo lint runs over every package; an unknown-rule disable is a hard error).
  13. Version 0.28.0

    HubSpot native on prod · multi-calendar · guest/SMS toggles · URL shortener+QR · Calendly importer

    Promoted to prod via the CI deploy:prod route (pipeline #2588076186); prod healthy on 713ca3a (migrations applied, worker healthy, HubSpot OAuth live, Sentry clean). Billing/coupon stay dormant on prod (no STRIPE_SECRET_KEY) — deferred to the prod Stripe go-live.

    — Added

    • Event type — host-authorized "add guests" + "SMS reminders" toggles (default OFF). The booking page only shows the "Add guests" field / the SMS-reminder field when the host turns them on; the booking server mirrors the gate (a forged POST can't bypass it).
    • URL shortener + QR codes (Team feature). New /links page lists every public link you generate (profile, event types, routing forms) with a short URL (/s/{code}), 1-click copy, and a QR code; plus add any custom URL to shorten + QR. The short URL is the SMS-friendly form. ShortLink model + /s/{code} redirect + /s/{code}/qr SVG.
    • Mail add-ins (MVPs) — a Gmail Chrome/Edge extension (extensions/gmail-link/, shipped) and an Outlook Office.js taskpane scaffold (extensions/outlook-addin/) to insert your booking link while composing.
    • Native HubSpot CRM proven end-to-end on staging — each booking's invitees + guests upsert as HubSpot contacts (meetings blocked by HubSpot's projects framework).
    • Calendly one-click import — verified end-to-end vs real Calendly accounts (free + Team, read-only): client + mappers + the full orchestrator import correctly and are idempotent on re-run. Surfaced on /vs/calendly with a "migrate in one click" block (EN/FR/ES), gated so it never shows where there's no importer (zcal).
    • Migration offer — "1st month free on annual": hosts who complete a Calendly import get a Stripe once coupon (~8.33%) auto-applied to their first yearly invoice at Checkout. Also hardened both billing routes to be Free-only server-side (no second-subscription via direct POST).
  14. Version 0.27.0

    Monetization pivot · org workflows · trilingual help · custom domains

    The big one. A full monetization pivot (ADR-0019), org-deployed workflows, the entire help center localized, the custom-domains infra armed, and ~10 agent waves' worth of fixes — all gated by 310 tests (42 core + 231 web + 37 integration) and five pre-promotion audits (code-review / payments / security / plan-parity / test-coverage; the one blocker — a cross-tenant workflow-delete IDOR — fixed + regression-tested). This is the SHA promoted to prod.

    — Monetization pivot (ADR-0019, Imad 2026-06-05)

    • 0% platform commission on paid plans (was flat 0.45%); 3% for Free hosts, on the amount charged. Real fee stored per payment (Payment.applicationFeeCents); admin finances sums real fees.
    • Payments (Stripe Connect) + meeting polls open to the Free plan (zcal/Calendly access parity).
    • Affiliate program → 41% RECURRING on every referred invoice (was 30% one-time) — new AffiliateCommission per-invoice ledger, retry-safe, 14-day hold via the reaper, admin bulk payout.
    • Org workflow templates (managed-events Phase 2) — an org admin authors a workflow template on /org/workflows and assigns it to members; each member gets a locked clone that re-syncs on every template save (the engine excludes template rows). Team-gated. Neither Calendly nor zcal ships this.
    • Help center fully trilingual (EN/FR/ES) — all 11 articles + a new /help/workflows article + "Every option, explained" reference tables on the 4 deep modules; the "published in English" caption removed.
    • Profile themes fixed + custom colour theme — preset themes were invisible (Tailwind purged their classes from lib/); now inline-styled (purge-proof) + a native colour picker persisting custom:#RRGGBB.
    • Growth surfaces — a public /affiliates page (41% recurring pitch), a recurring affiliate dashboard, a Free→Pro fee-savings nudge, /for/{coaches,tutors,freelancers} landers, and an affiliate-liability line in /admin/finances.
    • Custom domains infra armed — Traefik catch-all (scoped E2E test on staging, full any-host catch-all on prod, priority below the explicit hosts so caledee.com/n8n always win); the application half (verify + middleware rewrite) already shipped in v0.26.
    • Pricing comparison table completed — branding, custom domain (Soon), deposits, managed team workflows added, mirroring lib/billing.ts exactly; fee story 3% Free / 0% paid.

    — Added

    • Settings → Security tab (0ceea54) — the change-password card moved from Profile to a dedicated password-section.tsx, plus a new Sign-in methods section: password state + Google / Microsoft rows with Connect (Auth.js signIn, same-email linking, email_verified gate upstream) and Disconnect (deletes the Account row; server-guarded so the last remaining sign-in method can never be removed; the UI explains + disables the button). i18n security.signin.* EN/FR/ES.
    • /vs/{competitor} comparison pages (e689b2c) — /vs/calendly + /vs/zcal, a dynamic vs/[competitor]/page.tsx route, honest + table-driven (no unbacked claims), added to the sitemap (priority 0.8 / 0.7). A caledee-comparison-page skill governs tone + structure. i18n EN/FR/ES.
    • Dashboard PDF guide (FR/EN) (6cfef50) — downloadable public/guides/caledee-guide-dashboard-{en,fr}.pdf surfaced via a download card on the /help index, plus first embeds in /docs/getting-started. Versioned screenshots under public/screenshots/ (dashboard, event-types list, availability editor), captured by scripts/shot.mjs.
    • CI integration-tests job (gating) (0f559e0) — .gitlab-ci.yml now runs the DB-coupled integration suite against a postgres:16-alpine service (migrates calee_test via globalSetup.ts), so the 27 integration tests now gate deploys. The suite grew by 9 feature-flag tests pinning featureKilled fail-open vs getFlag fail-closed.
    • Demo-account seeder (0ceea54) — scripts/seed-demo-account.cjs provisions a content-rich demo account for tutorial-keeper screenshots: local-only (refuses to run unless DATABASE_URL is localhost), never admin, idempotent. Team plan, 5 event types (incl. paid + deposit), 17 bookings (past/future/canceled), a 3-member org, a voted poll, an org routing form, a workflow.
    • integrations-lead + ia-architect agents (a18b620, b941508/3c90002) — a connections-page regrade agent and an information-architecture scout (label truth, admin grouping, dashboard activation order); first runs archived under vault/domain/audits/.

    — Changed

    • Connections page regrade + integrations catalog filter (a18b620, integrations-lead wave 1) — the /connections page reworked, a filterable /integrations catalog (catalog.tsx), and /docs/{api,mcp,oauth,payments,webhooks} truthfulness fixes.
    • Round-robin + collective kill-switches wired (0f559e0, wiring-audit FF-1) — round_robin_assignment + collective_events feature flags were inert (zero readers); now wired (RR killed → owner hosts; collective killed → no slots, booking re-check 409s).
    • Auth deep-link ?next= on bounces (0f559e0) — middleware x-pathname + loginRedirectTarget so an auth bounce returns the user to the page they wanted; /verify/expired page added.

    — Fixed

    • ALL multipart uploads failed on staging/prod (e571a8d) — Sentry's http integration captured incoming request bodies, consuming the multipart stream before req.formData() ("Response body … disturbed or locked"). Local dev was immune (no DSN). Fixed with httpIntegration({ maxIncomingRequestBodySize: "none" }) — also a posture win (no request-body PII in Sentry). Cover-image uploads were the visible symptom.
    • Cross-tenant workflow-delete IDOR (pre-prod security gate) — deleteWorkflowAction cascaded child deletion by sourceTemplateId with no ownership check; any host could wipe another org's managed-workflow children. Gated on template ownership + regression test.
    • Admin finances over-counted commission (pre-prod payments gate) — the 0%-fee paid-plan case stored null, which the legacy bucket 0.45%-estimated; now stores the real 0, with a PIVOT_DATE cutoff + EUR currency-bucketed affiliate liability.
    • HSTS on custom domains — the global includeSubDomains; preload asserted authority over customers' apex subdomains; middleware now strips it to a plain max-age on verified custom hosts.
    • Cover image 2 MB → 8 MB cap — real hero photos 413'd on the shared image cap; dedicated cover cap + per-field hint.
    • Webhook auto-disable threshold documented as 50, code is 15 (docs-only, flagged by integrations-lead) — docs/INVENTORY.md said "auto-disable après 50 fails"; the code is MAX_CONSECUTIVE_FAILURES = 15 in lib/webhooks.ts (the public /docs/webhooks page already correctly says 15). INVENTORY corrected.
    • Checkout 500 no longer echoes e.message (0f559e0, errors-audit) — /api/billing/checkout returns a generic message + Sentry capture (money path).
    • BullMQ workers capture FINAL job failures to Sentry (0f559e0) — exhausted email / reminder / webhook jobs were console-only; now captured.
    • MCP cancel / reschedule never rethrow raw errors (0f559e0) + Zapier cancel_booking routes through the shared cancelBooking (same defect class as MCP M2).
    • featureKilled fails open on a DB error (0f559e0) — kill-switch read errors no longer hard-block the surface (pinned by the new feature-flag integration tests).
    • Booking-flow required gates for file / multi_select questions (0f559e0, forms-audit) — a hidden-input required is a no-op; client-side gate added. blockReason extended to urlInvalid / videoInvalid; custom-domain on-blur validation mirror.
    • i18n coverage (0f559e0, wave-2) — u/[slug], team/[orgSlug], invitations, routing interstitial, waitlist claim now localised; admin system-docs drift fixed (worker rows + row counts); requireAdmin enforced in-page.
    • Marketing truthfulness (3ce8d78, e689b2c) — competitor names dropped where unverifiable, v1.0 pill, honest Enterprise / SMS / connector badges, honest self-host claim.
  15. Version 0.26.0

    Mega backlog sweep: security wave completion + uploads + help center + monetization

    Everything since v0.25.0 (2026-06-04), on the staging trunk: a large competitive-scout backlog sweep + the deferred security/audit P-items — the security wave completion (S6 tenant isolation + S12 vote email-confirm + /oauth/authorize rate-limit), a vitest test-DB harness (18 integration tests), the upload primitive (file questions + welcome video + avatar/cover), org-shared routing forms, a help center (/help) + canned responses, custom domains (Pro) (code; infra gated), deposits / partial payments, profile themes, a cash affiliate MVP, MS Graph push notifications, a comp reaper, admin referrals + payments CSV, MCP +3 write tools (→13) + API v2 /leads+/contacts, a /changelog.xml RSS feed, and "Draft with Claude". ~17 commits, ending at a6ae188. Staging only — prod promotion (master fast-forward) is Imad's separate gated step.

    — Security

    • S6 — routing-form tenant isolation (64ef2d0) — public routing forms moved from /f/{slug} to /f/{host}/{form} (mirroring /u/{slug}/{eventSlug}); old single-segment URLs 308-redirect to the new shape for the migration window. Slug collisions across tenants no longer leak whichever host's form was created first. Data was already @@unique([userId, slug]).
    • S12 follow-up — poll-vote email confirmation loop (ae43de0) — the unauthenticated /p/[token] vote path gains an opt-in click-to-confirm email step: a new /p/[token]/confirm route + poll-vote-confirm React Email template (catalog row added). Completes the S12 abuse-hardening started with the IP rate-limit.
    • /oauth/authorize rate-limit (64ef2d0) — the authorize page + approve/deny actions now call limit() like every other OAuth surface (was the one un-limited endpoint).

    — Added

    • Upload primitive + file questions + welcome video (d550d1a, cbb06c0, 848c84e) — a first-class upload pipeline: UploadedFile model + POST/GET /api/uploads (size/type guarded) + shared upload-field.tsx + lib/upload-storage.ts (migration 20260604151120_upload_primitive). Powers logo / avatar / cover uploads in branding, a new file custom-question type (Pro) so invitees can attach a document at booking, and a welcome video on the booking page (#14 — an allowlisted YouTube/Vimeo/Loom URL or an uploaded file, parsed by lib/video-embed.ts). lib/uploads.test.ts + lib/video-embed.test.ts.
    • Funnel analytics (BookingPageView) (cbb06c0) — public booking-page views are recorded (lib/booking-analytics.ts) so the host dashboard can show a views → booked funnel and no-show rate. We already had the booking rows; this captures the top of the funnel. Migration 20260604152206_booking_page_view.
    • Org-shared routing forms (cbb06c0) — routing forms can be shared org-wide instead of staying per-user; the /routing-forms listing and detail respect the org scope.
    • Booking follow-up prompt (cbb06c0) — a post-booking "next step" nudge component (booking-followup.tsx) on the booking flow.
    • Help center (/help) + canned responses (848c84e) — a public, searchable self-serve KB: 10 articles (getting-started, event-types, availability, bookings, branding, billing, integrations, meeting-polls, routing-forms, teams) with a nav + search shell, reusing the /docs infra; plus reply macros / canned responses in /admin/support to cut ticket volume. i18n EN/FR/ES.
    • "Draft with Claude" admin reply assistant (8a965ae, #11) — an admin button in /admin/support that pre-fills a suggested reply (human edits + sends) via lib/claude.ts. Turns the Lot 21 inbox into an assisted desk — the differentiator the inbox was built to enable.
    • Custom domain (Pro+) — code half only (848c84e) — bring-your-own-domain for the public profile: CustomDomain model + settings form + custom_domain Pro gate (lib/billing.ts) + edge→node resolver (/api/domains/resolve) + middleware host-rewrite (verified rows only) + DoH ownership verification. The Traefik catch-all router + on-demand TLS on the prod VPS is NOT yet applied (Imad-gated — shared proxy with n8n) and the pricing/marketing claim is deliberately HELD until it is. See docs/operations/custom-domains.md. lib/custom-domains.test.ts.
    • Deposit / partial payment (#17a) — paid event types can now collect a deposit at booking time instead of the full price. The event-type editor's payment section gains a "Collect" choice — full amount (default, unchanged) vs a deposit (fixed € or %) — with a live computed example. New EventType.depositType (none|fixed|percent) + depositValue (cents for fixed, 1..99 for percent), Pro-gated like requiresPayment. The booking flow charges lib/deposits.computeChargeCents (1 € floor; the 0.45 % platform fee is unchanged — still computed on the charged amount); the invitee sees explicit "Pay €X deposit now · €Y balance due at the session" copy on the booking page, the payment step, and the receipt email. Payment gains additive depositAmountCents? + totalAmountCents? so finances/admin distinguish a partial charge; refund-on-cancel reverses the charged amount (deposit refunds work with no full-price math). No balance auto-charge in v1 — settled off-platform (stated in the host editor help). Migration 20260604154133_deposit_partial_payment_and_profile_theme. lib/deposits.test.ts (20 cases). i18n EN/FR/ES (events + booking + email namespaces).
    • Profile cover themes (#16) — a small curated set of 6 presets (sunset / ocean / forest / lavender / slate / rose), each an accent + subtle gradient on the public profile header (/u/[slug]). Stored as User.profileTheme (VarChar(24)?, null = default look), validated against the fixed catalog in lib/profile-themes.ts. Branding settings gain an épuré swatch row (tiny gradient circles + "None"). A cover image, if set, takes priority over the theme. Restrained by design — "a few themes, not a zoo". i18n EN/FR/ES.
    • Cash affiliate program MVP (2724ac3, #20) — distinct from the Lot 20 credit referral: a real cash-acquisition loop. A /a/{code} route drops an attribution cookie; hosts opt in from Settings → Refer & earn; the affiliate earns 30 % of the referred account's first invoice, tracked on a new /admin/affiliates board. AffiliateAccount + AffiliateConversion models (migration 20260604160934_affiliate_program), Stripe-webhook-driven conversion capture, lib/affiliates.ts (+ .test.ts). MVP — payout is manual/admin-tracked, not yet automated. i18n EN/FR/ES.
    • Microsoft Graph push notifications (f68e8dc) — /api/webhooks/microsoft-graph change-notification subscriptions, symmetric to the existing Google Calendar push; free/busy cache invalidated on Outlook calendar changes.
    • Nightly VPS DB dumps (v1) (f68e8dc) — a pg_dump cron on the VPS (local disk). S3 offsite copy + PITR are the v2 follow-up (Imad-gated).
    • Admin payments CSV export + per-status counts (6a3d74f) — /admin/payments gains per-status count chips (whole-board shape) + a CSV export route, so operators can reconcile finances without DB access (payments-audit P2).
    • /admin/referrals oversight (6a3d74f) — every ReferralReward with its hold-window state, eligibility + void reason. The last referral go-live code gate (flip REFERRAL_PROGRAM_ENABLED is Imad's).
    • Comp reaper cron (ae43de0) — a nightly cron hard-reverts expired comps (compedUntil <= now → plan=free / status=canceled / compedAt=null) so /admin/subscriptions + compedCount stop drifting (payments-audit P2).
    • API v2 /leads + /contacts (db7559b) — two new public REST endpoints (POST /api/v2/leads, GET /api/v2/contacts) behind new PAT scopes read:leads / read:contacts; OpenAPI spec updated. lib/contacts.ts (+ .test.ts).
    • MCP write depth — 3 new tools (→13) (db7559b) — reschedule-booking, create-availability-override, set-out-of-office added to the remote MCP server, each per-tool scope/plan-gated. lib/availability-overrides.ts (+ .test.ts).
    • Support reply email (db7559b) — the Lot 21 deferred piece: a staff reply now also sends a transactional support-reply email (catalog row added) so users hear back off-app, alongside the in-app bell.
    • Dashboard no-shows / outcome panel (db7559b) — a meeting-outcome panel (no-shows, completed) on the host dashboard.
    • /changelog.xml RSS feed + auto-fed changelog (bd1adb7) — lib/changelog.ts parses the repo-root CHANGELOG.md (tolerant Keep-a-Changelog parser) and feeds both the public /changelog page and the new RSS feed from the same model, so they can't drift. Release notes stay English-only by design (page chrome is localised). lib/changelog.test.ts.
    • vitest test-DB harness + 18 integration tests (d550d1a) — apps/web/test/integration/ + vitest.integration.config.ts (global setup spins a throwaway test DB, factories, env-guard). 18 tests across billing / managed-events / oauth / support-authz — the harness that was blocking real authz/oauth/payments DB-flow tests now exists.

    — Changed

    • Annual billing anchor on pricing (db7559b) — the pricing toggle/table anchors on the annual price.
    • Developer terms clause (db7559b) — /terms gains an API / OAuth-provider / MCP developer section (the integrations page markets these as live, so the legal copy now covers them).

    — Removed

    • Legacy EventType.managed boolean dropped (bd1adb7) — the inert Lot 4 column, superseded by the Lot 22 isTemplate / sourceTemplateId / lockedFields managed-event model. Migration drops the column.
  16. Version 0.25.0

    Lot 22 Phase 1: managed event types MVP

    Everything since v0.24.0 (2026-06-03), built on the staging trunk: meeting-poll invitee-proposed slots, poll post-creation editing, poll→booking pre-fill, org-analytics i18n, auth ?next= honoured on every sign-in path, a same-day forms/wiring/i18n/copy audit sweep, a dev-only CSP fix — and Lot 22 Phase 1: managed event types (admin templates with per-section locks + sync, named org groups with auto-assign, Team+ gating). Plus three new agents and a fleet re-index. Staging only; not promoted to prod (that's Imad's separate gated step). See ADR-0017. Context: prod (master) was promoted 2026-06-04 from ee8036f → 1b370b3 (the v0.24.0 window) on Imad's explicit instruction. This v0.25.0 tag sits ahead of that on staging.

    — Added — Lot 22 Phase 1: managed event types

    • Managed event types MVP (746830a, 13a92a7) — an org owner/admin authors an event-type template and assigns it to members (individually, by named group, or to everyone); each assignee gets a real event type generated from the template on their own page. Per-section locks: the admin marks sections (duration, location, questions, …) locked → they stay read-only on the child and auto-sync on every template save; unlocked sections fork per member. Named org Groups (OrgGroup / OrgGroupMember) with auto-assign so new members joining a group inherit its managed events. Assignee lock-down UI, slug hide + rename on children, never-bookable templates (the template itself never appears on a public page). Admin surfaces at /org/managed-events + /org/managed-events/[id]. Team+ gated (managed_event_types capability); on downgrade, managed events are paused (not deleted). E2E-verified. Schema: EventType.isTemplate + sourceTemplateId self-FK + lockedFields String[], ManagedEventAssignment ledger, OrgGroup / OrgGroupMember; migration 20260604105001_managed_event_types_lot22; the legacy EventType.managed boolean is left inert. ADR-0017 + docs/specs/lot22-managed-event-types.md. lib/managed-events.test.ts (5 cases). Phase 2 (cross-org admin center, richer lock granularity, reassignment) is backlogged.

    — Added

    • Invitee-proposed poll slots (63885d8) — opt-in MeetingPoll.allowSuggestions (default false). When the host ticks "Let invitees propose times" at creation, the public vote page (/p/[token]) gains a "None of these work? Propose a time" card: a visitor adds a slot (date + time in their timezone, converted via the shared zonedWallClockToUtcIso) and it joins the list for everyone to vote on. suggestSlotAction is unauthenticated and hardened: IP rate-limited (10/10min), enforces allowSuggestions + open + not-expired + ≤20 slots + future start + no instant-level overlap; slot length mirrors the host's first slot (default 30 min). MeetingPollSlot.suggestedBy (null = host slot) drives a "proposed by {name}" pill on the host detail page and the vote list. Localised poll.json namespace EN/FR/ES with live validation + error-code mapping. Migration 20260603185339_poll_invitee_suggestions. Calendly has no equivalent — genuine differentiator.
    • Meeting poll post-creation editing (1b370b3) — new /polls/[id]/edit, the /polls/new editor pre-filled (title, description, event type, expiry, allowSuggestions, slots as host-tz drafts). Open polls only (confirmed/closed redirect to detail). updatePollAction reconciles slots by instant (minute-truncated): kept starts keep their votes + suggestedBy, removed slots cascade their votes, new starts must be future + non-overlapping; expiry has a "keep current" sentinel vs re-anchor N days / clear. Edit button on the detail share card. Shared label builder extracted to polls/form-labels.ts. Matches Calendly's editable meeting polls — parity.
    • Poll → booking pre-fill (9402b67) — confirming a poll slot now shows a "Next step" card and stamps MeetingPoll.confirmedBookingId (previously a permanently-null ghost column) so the host can carry the agreed time straight into a booking. Closes the first half of wiring-audit items F5/F6.

    — Changed

    • Auth ?next= honoured on every sign-in path (ec10dba) — social sign-in and magic-link now carry ?next= (and callbackUrl) through to the post-auth redirect via a shared safeNextPath helper applied everywhere; invite-accept and OAuth-consent flows no longer dead-end on /dashboard. lib/safe-next.test.ts (7 cases).
    • Org analytics page (/org/analytics) wired to i18n (d5a6c4c) — the last hardcoded-English app page now routes its gated / no-team states, StatCard labels, table headers and role pill through t() (org.analytics.* in the app namespace, EN/FR/ES parity); header gains the grade coral mono eyebrow.
    • Poll forms (63885d8, 1b370b3) — new-poll form adds the allowSuggestions checkbox (label + help) and mode/initial props so it serves both create and edit; polls.edit.* + polls.detail.edit + poll-suggestion i18n added EN/FR/ES.
    • Three new agents + fleet re-index (891f1d3) — integrations-lead, marketing-director, agent-architect added to .claude/agents/ and the fleet re-indexed.

    — Fixed

    • Audit-sweep fixes (9402b67, 20ba8c0) — forms / wiring / i18n / copy audits run 2026-06-04: vote-page localization, poll-suggest live validation, dead labels removed, pastSlot copy, canceled-status fallback, bookings.newPage.lead truth fix, Slack "soon" → "live" in marketing copy.
    • Dev-only CSP hydration bug (1b370b3) — next dev evals source maps / react-refresh, so the strict script-src Content-Security-Policy silently killed hydration on every local dev page (the page rendered fine but no click worked). next.config.mjs now appends 'unsafe-eval' to script-src only when NODE_ENV=development. Prod CSP is unchanged.
  17. Version 0.24.0

    Lot 21: in-app support inbox

    Everything since v0.23.0 (2026-05-29): the batch-3 redesign sweep (dashboard → event-types → bookings → routing-form → meeting-poll → CRM, all zero-regression), billing-transparency phases, a shared timezone-picker module, the blog/SEO additions, a poll-creation crash fix with a guarded bug-class, and Lot 21 — the in-app support inbox. Staging only; not promoted to prod (that's Imad's separate gated step). See ADR-0016.

    — Added — Lot 21: support inbox

    • User support inbox (/support) — Pro+ hosts open a ticket (need / idea / bug / other): list (sorted by last reply), new-ticket form (kind + subject + details, with a live disabled-reason hint so the button never reads as broken), thread view with a reply box + "mark as resolved". Ownership enforced on every read/write. Free users see an upsell card; gated by a new priority_support capability in lib/billing.ts.
    • Staff triage inbox (/admin/support) — inbox with status-filter pills + counts + an "awaiting reply" dot, a staff reply box (system/cyan), and a status changer. requireAdmin on every read and mutation; status changes via id-scoped updateMany.
    • In-app reply notification — a staff reply writes a support.replied bell notification, localised to the requester's preferredLocale (support.notify.replied, EN/FR/ES).
    • Claude-readable support — scripts/support-inbox.mjs, a read-only CLI (list / --open / --all / <id> / --json) for in-chat triage; replies stay a human/admin-UI action (no agent write path).
    • Schema — SupportTicket + SupportMessage models with SupportTicketKind / SupportTicketStatus / SupportAuthorRole enums; denormalised lastReplyAt/lastReplyBy head for cheap admin sort/filter; cascade delete from User and SupportTicket. Migration 20260603125738_support_inbox. ADR-0016.
    • i18n — support.* across the app + admin namespaces + nav.support in the dashboard and admin sidebars, all EN/FR/ES (no new orphan namespace; keys live in already-loaded files). 14 Zod-schema vitest cases (support/schema.test.ts).
    • SEO — new long-form guide "GDPR-compliant scheduling software" + a blog-sitemap fix (public content, still a draft on staging).

    — Changed

    • GDPR Art. 20 export now includes the user's support tickets + messages (lib/data-export.ts); admin db-stats + system-docs list the two new models.

    — Fixed

    • Silent poll Create failure — the meeting-poll create handler threw an Invalid time value (tz shortOffset math) before the server action, and the throw was swallowed by startTransition → the Create button looked broken with no error. Fixed via a hardened lib/format.ts zonedWallClockToUtcIso helper (+ offset/DST unit tests) and guarded the bug class in the forms / errors / test-coverage audit agents and technical-gotchas.md.
    • Support inbox audit sweep (10 agents) — closed a P0 (requireAdmin was missing on the admin list read), made admin status changes id-scoped updateMany, re-gated reply on downgrade, fixed an FR gender agreement (support.status.answered "Répondue"), and bumped a thread-timestamp ink shade for WCAG AA contrast.
  18. Version 0.23.0

    Lots 19–20 + product-scout sprint + UX polish

    Everything since v0.22.0 (2026-05-21): the security/wiring/audit waves, lead-capture + waitlist, Lot 19 (OAuth provider + MCP server + Zapier + Slack + integrations page, plus hardening), Lot 20 (two-sided referral program, gated off), the product-scout sprint (contacts, admin finances, today agenda, round-robin, recurring bookings, workflow triggers/actions), and the closing UX polish (settings de-dup, hardcoded-French fixes, sitemap/robots SEO fix, ux-ui-audit agent, charte reconciliation, CI build-cache cap).

    — UX polish + new ux-ui-audit agent

    • Settings de-duplication — API tokens + webhooks were rendered in both the Security and Developers tabs. Now sole-homed in Developers (alongside OAuth apps + API docs); Security keeps sessions + 2FA + a pointer card to Developers. New security.devTools.* keys (EN/FR/ES).
    • Hardcoded-French strings fixed — the cookie banner (cookie-banner.tsx, was French on every locale) and the pricing comparison-table header (pricing-table.tsx) are now i18n'd via new common.cookies. + pricing.comparison.compare keys (EN/FR/ES). The banner takes labels from the root layout.
    • Sitemap/robots SEO fix — both emitted http://localhost:3000/... because Next statically pre-rendered them at build (where APP_URL isn't set). Now force-dynamic so they read the runtime container env → correct domain.
    • ux-ui-audit agent — new robust read-only agent that renders real pixels (Playwright, desktop+mobile, light+dark) and critiques every surface against the design handoff + WCAG 2.2 AA + Nielsen heuristics, audits and ideates. First scoped run → vault/domain/ux-ui-audit-2026-05-29.md.
    • Charte reconciliation — clarified that the Claude Design 2026-05-19 handoff (coral accent, warm-ink neutrals, cyan-as-info, Inter+JetBrains-Mono) is canonical; charte/design-system.md (teal/slate) was the obsolete leftover → deprecation banner + colour/type sections realigned; charte/README.md now points at the handoff.

    — Product-scout sprint

    • Contacts page (/contacts) — person-centric view merging booked invitees + captured leads by email (meetings, no-shows, last activity, client/lead badge, search, deep-link to bookings). Free to view; CSV export is Pro+. Derived data, no new table.
    • Admin Finances — real MRR/ARR/churn (from Subscription, monthly-equivalent, per-seat) + GMV + 0.45% commission + referral credited/pending/void. First on the overview, then moved to a dedicated /admin/finances page with its own sidebar entry (shared lib/admin-finances.ts).
    • Host "Today" agenda — dashboard timeline of today's meetings (host tz) with free-time gaps + past/live ("Now") markers.
    • Round-robin distribution modes — RoundRobinStrategy (least_busy / equal / priority) on the event type + per-host priority; engine + form select + per-host priority inputs (migration 20260529120000).
    • no_show workflow trigger + generic event-dispatch (also wired booking_canceled, previously a dead/selectable ghost) (migration 20260529100000).
    • notify_host_inapp workflow action — in-app bell as a workflow action across all triggers (migration 20260529130000).
    • Recurring bookings — weekly/biweekly up to 12, DST-safe occurrence math in packages/core (5 vitest cases), book-available/skip-conflicts, shared recurrenceGroupId, Pro+ one-on-one non-paid; event-type toggle + invitee selector + booked/skipped summary + cancel-whole-series (migration 20260529140000).
    • Home trust strip (honest EU/GDPR + open-API/MCP + no-data-sold), annual-billing nudge + value-anchored trial banner, Refer & earn dashboard entry (gated), booking-page signup cross-sell ("Powered by Caledee → Create your own — free"), /accessibility statement page. New Pro+ features in lib/billing.ts: recurring_bookings. New i18n keys across EN/FR/ES throughout. All validated (typecheck 9/9, lint, 42 core + 55 web tests).

    — Post-Lot-20: audit loop + product quick wins

    • Public booking i18n bug: the shared MonthCalendar hardcoded English weekday headers → FR/ES invitees saw English. Now locale-aware via Intl. (i18n-audit gained a dimension for hardcoded label arrays in shared components — it had missed this.)
    • Referral robustness: Sentry on silent catches; the release cron reconciles a failed referee credit; credit capped at amount_paid; orderBy so the tail never starves.
    • stripe-live-setup.mjs now registers a Connect webhook endpoint too (booking direct charges fire on connected accounts); the handler verifies against either secret (STRIPE_WEBHOOK_SECRET_CONNECT).
    • Cleanups: admin referrals query (id not slug), /admin/system inventory refreshed, dead COMPARISON array removed, /oauth/userinfo rate-limited, Slack connect links fixed. New agent product-scout — grounded feature/enhancement ideation (vault/domain/product-scout-2026-05-28.md). Product quick wins (from the scout's ranked backlog):
    • Host earnings panel on the dashboard — surfaces the Payment data we already collect (collected / net after the 0.45% fee / refunded, per currency, 30d + all-time). Renders only when the host has payments.
    • no_show workflow trigger — new WorkflowTrigger.no_show (migration 20260529100000) fired from the no-show toggle via a generic applyWorkflowsForEvent(), which also wires booking_canceled (previously selectable in the builder but never fired).
    • Home trust strip — honest EU/GDPR + open-API/MCP + no-data-sold signals (not fabricated testimonials).
    • Annual-billing nudge for monthly subscribers + value-anchored trial banner (booking count).
    • Refer & earn dashboard entry, gated on REFERRAL_PROGRAM_ENABLED so it appears at go-live. Stripe go-live prep: docs/operations/stripe-go-live.md runbook + apps/web/scripts/stripe-live-setup.mjs (idempotent, env-only key, live-confirm guard). Reward math + balance-credit + idempotency verified against real Stripe test prices.

    — Lot 20: Referral program

    • Reward: when a referred user pays their first invoice, both sides get a Stripe customer balance credit worth monthsFree months of the converting plan at its per-seat price — monthly → 1 month, annual → 2 months. Team uses the per-seat unit price (so "1 seat only" is automatic; quantity is never multiplied in).
    • Gating + anti-abuse: fires only on the referee's first PAID invoice (amount_paid > 0); referee credited immediately (lands on their 2nd month), referrer credit held 14 days then released by a daily cron if the referee is still active (else voided). Self-referral blocked, one reward per referee (unique refereeId), cap 10 rewarded referrals per referrer, idempotent webhook + Stripe idempotency keys. Free referrers' credit parks on their Stripe balance.
    • Schema: ReferralReward + ReferralRewardStatus (migration 20260528100000_add_referral_rewards).
    • Surfaces: Settings → Refer & earn (link + stats, EN/FR/ES), admin db-stats row, invoice.payment_succeeded webhook hook, referral:release:daily scheduler.
    • Kill-switch: REFERRAL_PROGRAM_ENABLED (off by default; stays off in prod until verified in Stripe test mode + Imad's go-live). Pure reward math unit-tested.
    • Pre-go-live TODO: referral clause in Terms; Stripe test-mode end-to-end verification.

    — Lot 19 hardening + fixes

    • Audit loop (9 agents) → closed convergent findings: MCP per-tool scope gating (read-token→write priv-esc), OAuth Team plan-gate chokepoint, mandatory client-auth on refresh, rate limits on token/revoke/MCP/Zapier/Slack, Slack cross-tenant fail-safe, freeBusy failures → Sentry, waitlist claim→book wiring, oauth-apps-section i18n. CI now runs web vitest.
    • Poll creation fixed (offset-datetime schema rejection broke "Create"); past dates blocked; booking-flow calendar extracted to a shared MonthCalendar reused by the poll creator. /workflows/new RSC function-in-labels crash fixed (same class as /polls/new).

    — Lot 19 Phase 1+2: OAuth provider + MCP server + Zapier + Slack + integrations page

    • 3 Prisma models: OAuthApp, OAuthAuthorizationCode, OAuthAccessToken. Migration 20260527100215_lot19_oauth_provider.
    • lib/oauth.ts (271 lines): generateAuthorizationCode, exchangeCodeForTokens, refreshAccessToken, revokeToken, validateAccessToken, getUserInfo.
    • 4 route handlers: GET /oauth/authorize (consent screen with app name, requested scopes, approve/deny), POST /oauth/token (authorization_code refresh_token grants), POST /oauth/revoke, GET /oauth/userinfo.
    • PKCE S256 required for public clients (SPAs, mobile, Claude Desktop).
    • 7 scopes: read:profile, read:bookings, write:bookings, read:event-types, write:event-types, read:availability, write:availability.
    • Per-token rate limiting reusing existing PAT infrastructure.
    • ADR-0014 (docs/adr/0014-oauth-provider-schema.md). MCP remote server (commit 58f37ff):
    • Streamable HTTP at /api/mcp, OAuth bearer authentication.
    • lib/mcp-server.ts (301 lines) using @modelcontextprotocol/sdk (official SDK).
    • 10 tools: list-bookings, get-booking, cancel-booking, list-event-types, create-event-type, update-event-type, list-schedules, get-stats, list-leads, get-booking-link.
    • Unique differentiator: no competitor has an MCP server. Zapier polling triggers + actions (commit fea6ec3):
    • /api/zapier/triggers: 3 polling triggers (new_booking, canceled_booking, rescheduled_booking).
    • /api/zapier/actions: 3 actions (create_booking, list_event_types, list_availability).
    • OAuth-authenticated via the new OAuth provider. Slack slash commands + notifications (commit fea6ec3):
    • /api/integrations/slack: slash command handler with SLACK_SIGNING_SECRET signature verification.
    • lib/slack-notify.ts: real-time booking notifications to configured Slack channels (booking created, canceled, rescheduled, no-show). Dashboard + docs wiring (commit 610c81b):
    • Lot 19 integrations wired into dashboard navigation.
    • Docs site sidebar updated with new integration pages. Integrations marketing page (commit ef59eb6):
    • /integrations with 35 integrations across 12 categories (calendars, video conferencing, payments, CRM, automation, messaging, analytics, forms, email marketing, developer tools, embed, enterprise).
    • Full i18n EN/FR/ES.
    • Linked from docs sidebar + marketing footer. Plan-parity fixes (commit 58f37ff, 10 findings from audit):
    • P0: duplicate event type bypass closed (was possible to create past plan limit via direct API call).
    • P0: follow-up email workflow now gated on plan tier.
    • P1: API endpoint plan check enforced on v2 routes that were missing it.
    • P1: UI plan badges added to /polls, /workflows, /access-tokens, /webhooks pages (upgrade prompt for free-tier users).
    • "Booking enhancements" card section added to event-type form (consolidates lead-capture + waitlist + payment toggles).
    • Plan-parity audit report at vault/domain/plan-parity-audit-2026-05-26.md. New agents:
    • oauth-audit.md — 9-dimension security analysis of OAuth implementation.
    • plan-parity-audit.md upgraded from 6 to 7 dimensions.

    — Lead-capture mode + Waitlist + pre-launch hardening

    • EventType.detailsFirst toggle: when enabled, the booking flow collects invitee name + email + custom question answers BEFORE showing the slot picker. If the invitee leaves without booking, their info is stored as a LeadCapture row (POST /api/leads, rate-limited).
    • LeadCapture Prisma model + migration 20260524120000_lead_capture_model.
    • booking-flow.tsx: new initial details step when detailsFirst=true; captureLeadAndPick() saves lead via API then transitions to slot picker.
    • Event-type form: "Collect details before time selection" checkbox in Visibility section. Persisted + duplicated on clone.
    • i18n: EN/FR/ES labels (events.json).
    • Calendly gates this to enterprise (via routing forms); Caledee offers at Pro tier. Waitlist (commit 11b309f):
    • EventType.waitlistEnabled toggle: when all slots are full, invitees can join a waitlist. On booking cancel, the first waitlisted person gets a 15-min claim window via email. If they don't claim, the next person is notified.
    • WaitlistEntry Prisma model + migration 20260524140000_waitlist.
    • lib/waitlist.ts: full lifecycle (joinWaitlist, notifyNextWaitlisted, claimWaitlistSlot, markWaitlistBooked, expireWaitlistEntry, cancelWaitlistEntry, getWaitlistCount).
    • POST /api/waitlist endpoint (rate-limited, validates waitlistEnabled).
    • /wl/[token] claim page: validates claim token + redirects to booking with prefill.
    • Cancel hook in lib/bookings.ts: triggers notifyNextWaitlisted on cancel when waitlistEnabled.
    • BullMQ: 3 new email job kinds (waitlist-confirmation, slot-available notification, expiry-check). Delayed job for expiry auto-notifies next in queue.
    • booking-flow.tsx: WaitlistForm component shown when waitlistEnabled + no slots available.
    • Event-type form: "Enable waitlist" checkbox in Visibility section.
    • i18n: EN/FR/ES labels (events.json).
    • Calendly does NOT have this -- genuine differentiator. Pre-launch hardening (2026-05-24, earlier commits):
    • F1 booking payments wired (commit 9717dc4): Stripe Connect PaymentIntent + Payment Element in booking flow. New booking-payment.tsx component with @stripe/react-stripe-js. Backend: startBookingPayment() called when requiresPayment is set.
    • Pre-launch audit (commit f794e52): 10 security/code fixes + 5 new test suites (82 tests total).
    • Maintenance mode (commit 5305a9f): exempt /admin + /login + /api/auth so admins can still operate during maintenance.
    • M1 routing-form slug determinism + M3 CSP script-src allowlist (commit aa814d0).
    • Google OAuth published to production (commit 58b6240): all Google users can sign in (unverified app warning until verification submitted).
    • OG image fix (commit 05db5f3): template literals to avoid Satori multi-child error. Waitlist polish + leads page (commit 6c98233):
    • 3 React Email templates: waitlist-confirmation, waitlist-slot-available, waitlist-expired. Styled per brand guidelines.
    • /leads page for hosts to view captured leads (from LeadCapture model).
    • GDPR transparency notice on lead-capture booking flow.
    • 90-day lead-expire cron (lead-expire:daily BullMQ scheduler) purges old LeadCapture rows for GDPR compliance.
    • 4 integration gaps fixed: email catalog includes waitlist templates, mobile menu includes /leads, data export includes leads, sidebar nav updated. 4 critical Stripe payment bugs fixed (commit 2f24bbc):
    • F-PUBKEY: NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY now read correctly in the booking-payment component (was undefined in production).
    • F-CONNECTED-ACCT: PaymentIntent created on the connected account (was creating on the platform account, so payment went to Caledee not the host).
    • F-SILENT-FAIL: booking flow now surfaces Stripe errors to the user instead of silently failing with a blank screen.
    • F-SEAT: per-seat auto-accept on Team plan invitation fixed (was not triggering syncSeatCount on auto-accept path). Per-seat billing for Team plan (commit c408e0b):
    • Subscription.stripeItemId stores the Stripe subscription item ID for quantity updates. Subscription.seatCount caches the current seat count.
    • syncSeatCount() counts org members, updates Stripe subscription quantity with proration, and persists locally. Runs on member join and leave.
    • Migration: 20260524160000_per_seat_billing.
    • Pricing: 15 EUR/seat/month (monthly) or 12 EUR/seat/month (yearly). 0.45% platform commission (commit 4f31b98):
    • application_fee_amount (0.45%, min 1 cent) added to every PaymentIntent on connected accounts. Stripe collects automatically and deposits in the platform account. Calendly charges 0.5%.
    • Marketing copy updated EN/FR/ES: pricing table shows "0.45% commission" row for Pro/Team. Marketing updates (commits de7c07d, 4930583, be97cb9, e48a86f):
    • Differentiators section added to marketing homepage (why Caledee over competitors).
    • All "Calendly" mentions in public marketing copy replaced with generic "competitors" references (commit 4930583).
    • Pricing page fully internationalized: 80+ keys via props, billing toggle copy fixes (commit be97cb9).
    • Privacy + terms pages: padding, max-width, and prose styling fixes (commit e48a86f). Plan gates (commit f8b44ce):
    • lead_capture, waitlist, meeting_polls features now Pro+ gated via canUse(). Free users see an upgrade prompt when trying to enable these. 5 Team-exclusive features (commit 4398ba7):
    • Webhooks + API gated to Team+: canUse('webhooks_api') check on webhook creation actions.
    • Team analytics dashboard (/org/analytics): per-member stats (bookings 30d, total, no-show rate, active event types). 4 stat cards. Team+ gated.
    • Managed event types (EventType.managed): admin creates templates that members can only toggle active/inactive. Migration: 20260524180000_team_features.
    • Team booking page (/team/[orgSlug]): public page listing all org members + their event types. Organization.slug field added (unique, cuid default).
    • Lead distribution: POST /api/leads auto-assigns to the org member with fewest leads (round-robin by count). LeadCapture.assignedToId field added. Documentation + vault (commits 2b6ac7a, ef6c8a6, 6ee0ec9, 1eb858f):
    • Legal risks analysis (vault/domain/legal-risks.md).
    • Stripe payment audit report (vault/domain/stripe-payment-audit-2026-05-24.md).
    • Wiring audit report (vault/domain/wiring-audit-2026-05-24.md).
    • Security audit report (vault/domain/security-audit-2026-05-24.md).
    • Technical gotchas: new-feature checklist (12 integration surfaces), no sed on JSON, no hardcoded i18n strings.

    — Ops bootstrap + Sentry live + Better Stack + mobile nav v2

    • docs/operations/stripe.md — 9-section ops manual (ownership, hard rules, locations, test playbook, live cutover blockers B1-B4, incident response, plugin skills). Test mode operational on staging.
    • docs/operations/microsoft-oauth.md — full ops manual; Claude-managed via az CLI (Caledee Entra tenant 4e177909-..., App Registration 41205dc2-..., all 6 redirect URIs registered).
    • docs/operations/google-oauth.md — testing-mode strategy doc; Imad-driven until gcloud CLI install + verification submission.
    • TL;DR rule files: docs/rules/{stripe,microsoft-oauth,google-oauth}.md.
    • CLAUDE.md updated with Stripe / Microsoft / Google / Sentry ops invariants. Sentry source-map upload confirmed live (commit 1d0220b):
    • app/global-error.tsx — Sentry-recommended for Next 15 App Router, catches React render errors above root layout.
    • next.config.mjs: silent: !SENTRY_AUTH_TOKEN — verbose in CI (shows Uploaded files to Sentry in deploy log), silent locally. Dropped deprecated disableLogger.
    • Pipeline #151 confirmed: stack traces now resolve to file:line. Sentry MCP wired (commit 5228c8a):
    • @sentry/mcp-server v0.35.0 (official, getsentry/ org) added to .mcp.json (gitignored). EU region de.sentry.io, org galaxee. 22 MCP tools available for in-chat issue triage.
    • Mutations (resolve/ignore/delete) require Imad's confirmation. Better Stack uptime monitors (commit bcea56c):
    • 5 monitors created via API: staging-health (keyword "status":"ok"), prod-health (keyword), prod-homepage (HTTP 200), caledee.fr redirect (301/302/308), TLS cert expiry alerts (14-day warning).
    • Daily health-check routine at 07:00 Paris via Claude Code remote routine (trig_01XXBwYpYnsUVRnJPEEZYrcV). Mobile nav rework (commit 9ee9b27):
    • components/mobile-drawer.tsx — generic reusable drawer with focus trap, Esc close, scroll lock, route-change auto-close.
    • app/(app)/dashboard-mobile-menu.tsx — hamburger drawer with 11 routes + sign-out.
    • app/(app)/mobile-bottom-nav.tsx reduced from 5 to 3 tabs (Dashboard / Event types / Bookings).
    • app/(marketing)/marketing-mobile-menu.tsx — hamburger drawer + LogIn icon shortcut on mobile.
    • i18n: drawer.open / drawer.close keys added to EN/FR/ES common.json. Header cleanup (commit fa449db):
    • Removed LanguageSwitcher + ThemeToggle from marketing header (footer-only per user directive).

    — Security Waves C/D/E + Wiring Wave 3 + Mobile + Footer

    • S7 SCIM bearer token at-rest hashing (sha256 + timing-safe compare, dummy compare on miss). Migration 20260523103000_scim_token_hash.
    • S8 clientIp() + admin-audit IP read the last XFF entry (Traefik appends), not the first hop (client-supplied).
    • S9 /verify/[token] + /reconfirm/[token] GET no longer mutate state. Both now follow render-form / POST-consumes pattern; defeats Outlook Safelinks / Gmail prefetch token burning.
    • S10 Locale cookie now httpOnly + secure end-to-end via new language-switcher-action.ts server action; the client switcher posts to it instead of writing document.cookie. Security Wave D (P2 abuse hardening):
    • S11 OAuth signIn refuses providers claiming email_verified=false.
    • S12 /p/[token] poll-vote rate-limited (30/IP/10min + 5/(token,email)/5min).
    • S13 Invitee name CR-LF + control chars stripped at lib boundary.
    • S14 Routing-form off-domain redirects route through a confirmation interstitial at /f/[slug]/redirect?to= that validates https only.
    • S15 CSPRNG (crypto.randomBytes) for User slug collision suffix.
    • S16 BullMQ + rate-limit Redis keys prefixed caledee:.
    • S17 dispatchBookingWebhook contract documented (picked-host wins).
    • S18 buildEventDescription scrubs CR-LF + control chars on calendar description fields.
    • S19 applyStripeSubscriptionToDb refuses metadata.userId rebind when the named user has a different stripeCustomerId already. Security Wave E (P3 defense-in-depth):
    • S20 DKIM/SPF/DMARC ops doc in docs/rules/security.md.
    • S21 embed.js requires absolute https:// URLs.
    • S22 auth-tokens createToken delete+create wrapped in $transaction.
    • S23 admin-audit failed writes surfaced to Sentry.
    • S24 Google OAuth revoke failures surfaced to Sentry. Wiring audit Wave 3 — features shipped (8 of 9):
    • M1 Dropped Invitee.firstName/lastName (migration 20260523110000).
    • M2 Surfaced Booking.rescheduledFromId on each booking row.
    • M5 Poll auto-close: expiresInDays field on /polls/new.
    • M7 Notification grouping by payload.bookingId.
    • F4 EventType.allowedTz form field (single IANA tz, server-validated).
    • F9 Admin user detail: "Referrals (sent)" count + "Referred by" lookup.
    • Deferred: F5/F6 polls confirm → auto-create Booking + prefill — needs careful integration with createBooking (calendar invites, emails, slot validation). Routed in ROADMAP under Lot 15. Mobile + footer UX:
    • Dashboard mobile bottom-tab nav (app/(app)/mobile-bottom-nav.tsx): Dashboard / Event types / Bookings / Settings / More. Hidden on md+ where the sidebar takes over. Respects safe-area-inset-bottom.
    • Marketing footer + dashboard sidebar bottom now host both LanguageSwitcher + ThemeToggle. Pairs the locale + theme conventions so users learn one footer pattern. Out of scope this session (still on ROADMAP):
    • F7 SSO admin seeder page (ADR-0012 scaffold completion) — Lot 16.
    • F5/F6 polls auto-create Booking flow.
    • Wave C S6 routing-form /f/[hostSlug]/[formSlug] URL change — public URL break, needs 301 plan.

    — Audits + agents

    • Five permanent project-level agents in .claude/agents/: wiring-audit, security-audit, doc-sync, lot-finalizer, agent-keeper. Reusable on demand for the recurring health-check workflows.
    • Weekly security pipeline in .gitlab-ci.yml — Semgrep SAST + pnpm audit gitleaks + OWASP ZAP DAST + Trivy. Notification-only at first; gated on SECURITY_SCAN=true so only the scheduled pipeline runs the stage. Operator doc at docs/infra/security-scans.md.

    — Wiring audit Wave 1+2 — 12 fixes

    • F1 Invitee.guests[] now actually delivers: CC'd on the invitee booking-created email AND added as attendees on every calendar event create (Google/Microsoft + collective fan-out). sendMail() accepts a validated cc[].
    • F2 Host's paymentReceipts email pref now actually gates the Stripe receipt enqueue in payments.ts.
    • F3 Email catalog stops claiming three pref gates that never existed in EmailPrefKey (bookingCreated/Canceled/Rescheduled).
    • F8 ensureStripeCustomer pre-fills address.country from User.country (ISO-2 validated) so automatic_tax kicks in faster.
    • F10 Three missing notify() calls added — no-show toggle, payment-succeeded webhook, org-invitation-accepted. The bell tray now surfaces every NotificationKind the union declares.
    • F11 OpenAPI Error schema matches the runtime apiError shape: { title, message, details[] } instead of { error, message, issues[] }.
    • F12 /docs/api links to /api/docs/openapi.json with a download snippet. ROADMAP item ticked.
    • F13 lib/free-busy-cache documents the in-memory INVALIDATIONS vs DB ConnectedCalendar.lastInvalidatedAt multi-instance gap as a TODO; harmless single-instance, must move to Redis when we scale.
    • M3 Payment.applicationFeeCents dropped (migration 20260522103000_wave1_wiring_drops) — v1 stance is no platform fee.
    • M4 Unused stripeConnectPayoutsEnabled select removed from /integrations.
    • M6 WebhookSubscription.lastFailureAt now rendered in Settings → Security (most-recent-attempt wins). New lastFailure key EN/FR/ES.
    • M8 Refreshed misleading noShowSelf catalog comment. Wave 3 (9 feature adds + 1 cleanup) routed into Lots 12/14/15/16 + backlog technical in docs/ROADMAP.md. Full audit report at vault/domain/wiring-audit-2026-05-22.md.

    — Security audit Wave A — 3 P0 prod-blockers

    • S1 Stored XSS via host's eventType.description interpolated into a <script type="application/ld+json"> block. Fixed with new apps/web/lib/safe-json-ld.ts that escapes <, >, &, U+2028, U+2029 in JSON.stringify output. </script> can no longer break out of the inline script.
    • S2 TOTP secrets now encrypted at rest via the existing AES-256-GCM helpers in lib/crypto.ts. Stored shape: enc:<base64>. Legacy plaintext rows are passed through transparently (no migration needed pre-launch). The schema comment that already claimed encryption is now true.
    • S3 X-Frame-Options: SAMEORIGIN sitewide removed (it silently broke the embed widget). Replaced with route-scoped CSP frame-ancestors: * on /u/:slug/:eventSlug (the embed target), 'self' everywhere else. /admin, /dashboard, /settings, login etc. stay clickjacking-protected.

    — Security audit Wave B — headers + cookies

    • S4 Sitewide CSP frame-ancestors $ROUTE; object-src 'none'; base-uri 'self'. Strict script-src deferred (needs nonce plumbing for JSON-LD + host-injected GA4/Pixel/GTM).
    • S5 HSTS max-age=63072000; includeSubDomains; preload. Submit to hstspreload.org after one week on staging.
    • S25 Permissions-Policy: camera=(), microphone=(), geolocation=(), interest-cohort=().
    • S10 Referral cookie now httpOnly + secure; locale cookie now secure in both middleware + settings/actions.ts. httpOnly on locale deferred to Wave C with a language-switcher refactor. Wave C/D/E (18 items, P1-P3) routed into a new "Security audit follow-ups" section in docs/ROADMAP.md. Full audit report at vault/domain/security-audit-2026-05-22.md.
  19. Version 0.22.0

    Lot 18.5 followup + UX/tech backlog + CI consolidation

    — Lot 18.5 follow-up

    • /(app)/dashboard/page.tsx fully wrapped (next-up hero, checklist, calendar nudge, stat cards, top event types, upcoming bookings) + uses Intl.DateTimeFormat(locale, ...) from user.preferredLocale.
    • /(auth)/login, /signup, /forgot-password + /reset-password/[token] /verify/[token] translated EN/FR/ES.
    • Hardcoded "fr-FR" / "en-US" Intl locales replaced with resolved locale on /r/[token], /u/[slug]/[eventSlug], /(marketing)/status, /(marketing)/blog. BookingFlow now accepts an optional locale prop.
    • MDX i18n strategy: per-locale MDX (en.mdx/fr.mdx/es.mdx) with a thin page.tsx dispatcher. Privacy + Terms migrated; FR + ES ship as "translation pending native legal review" placeholders pointing back to the canonical English.
    • Locale-aware Zod errors: new errors.json namespace + lib/zod-i18n.ts localiseZodError(err, locale) walks ZodIssue codes.
    • messages/_glossary.md codifies non-negotiable translations + tone (FR vouvoiement, ES tuteo).
    • scripts/extract-i18n.ts — triage extractor for un-wrapped JSX text + aria-label / placeholder / title attribute literals.

    — UX backlog

    • EventType.thankYouMessage (VarChar 500) — host-authored note on booking confirmation. Threaded into BookingFlow done step.
    • "Request a different time" mailto link in BookingFlow no-slots state.
    • Keyboard shortcut overlay (? key) — modal listing g d / g e / g b / g s / g a / n navigation shortcuts.

    — Technical backlog

    • User.holidayCountry (VarChar 2) — public holidays auto-close the bookable surface. lib/holidays.ts ships fixed + Easter-relative holidays for FR/ES/US/GB/DE; layered into loadSchedulingContext as empty date_overrides.
    • Structured logger (lib/logger.ts) — middleware mints/propagates x-request-id/x-invoke-path/x-invoke-method on response headers, logger reads them via next/headers. One JSON line per call.
    • Calendar push-renewal cron — new calendar:renew:12h BullMQ scheduler fires every 12h; the email worker's calendar:renew-watches branch re-subscribes Google channels with watchExpiresAt < now + 24h.

    — CI / infra

    • All validate jobs (typecheck/lint/unit-tests) now run on the self-hosted caledee-vps runner. Shared-runner quota depleted → routed 100% off platform via default: tags: [vps]. Removed the dual shared/vps split.
    • scripts/sync-staging.sh — idempotent fast-forward of origin/staging to master HEAD. Used after each commit to keep both deploy lines pipelined off the same SHA. Migration: lot18_5_ux_backlog (EventType.thankYouMessage, User.holidayCountry).
  20. Version 0.21.0

    Lots 15 / 16 / 17 / 18.5 (foundation)

    — Lot 15 — Meeting polls (Doodle-style)

    • MeetingPoll + MeetingPollSlot + MeetingPollVote models, host UI at /polls, /polls/new, /polls/[id]. Public vote page at /p/[token] with checkbox multi-select + invitee tz auto-detect. Re-vote idempotent via (slotId, inviteeEmail) composite-unique upsert.
    • Per-event-type calendar sub-tab: EventType.calendarConnectionId honored by loadBusyAndCounts + syncBookingToExternalCalendars, graceful fallback to oldest active when ID is stale.
    • Collective events: per-host calendar fan-out via BookingExternalEvent.userId, group-by-user cancel path.

    — Lot 16 — routing_form_submitted + scaffolds

    • routing_form_submitted webhook event with HMAC payload, Slack reshape, Teams Card reshape. WebhookJob discriminated union (booking | submission).
    • SAML 2.0 + SCIM 2.0 scaffold: OrganizationSso model, routes return 501 (SCIM-error-shaped per RFC 7644), auth real. Full handshake gated on IdP test tenant. See ADR-0012.
    • HubSpot / Salesforce / Intercom CRM connection scaffold + pushBookingToCrms fanout wired into createBooking. Real API calls gated on sandbox tokens.

    — Lot 17 — React Native scaffold

    • apps/mobile/ Expo SDK 52 with expo-router tabs (dashboard / bookings / settings), dev login form, lib/api.ts thin client.
    • Native auth + push notifications gated on Apple Developer enrolment.

    — Lot 18.5 — Translation foundation

    • Four new namespaces × three locales = 12 JSON files: dashboard.json, settings.json, auth.json, marketing.json with EN baseline + FR + ES drafts.
    • App shell (app/(app)/layout.tsx) — sidebar nav labels + sign-out + mobile quick-nav. SidebarNav refactored to labelKey discriminated union so the parent owns the i18n.
    • Marketing shell (app/(marketing)/layout.tsx) — top-bar CTAs + full footer.
    • Pattern documented in ADR-0013. Remaining surface migrations (settings forms, auth pages, marketing inner pages, MDX) are incremental follow-ups. Pre-v0.21.0 (Lot 13 / 14 / 16 / 17 work from the previous unreleased window, all included in this tag):

    — Lot 13 — admin deep features (Waves 1–5)

    • /admin/subscriptions paginated with attention-status sorting
    • /admin/payments (failed by default, ?all=1 for everything)
    • /admin/emails outbound log filterable by template/status/recipient
    • /admin overview: 7-day bookings bar chart + activation funnel
    • Left-sidebar layout to match the main app Wave 2 (3089cce):
    • Announcement model + /admin/announcements + sitewide banner with info/warning/critical levels
    • FeatureFlag model + /admin/feature-flags with deterministic SHA-256 bucketing for % rollout Wave 3 (facf742):
    • AdminAuditLog model
    • Impersonation (HMAC-signed cookie swap, 30-min TTL, requireAdmin re-check every request, banner + stop action)
    • Manual plan override
    • Per-user admin history timeline Wave 4 (91306e3):
    • /admin/email-catalog — every email type + ASCII pipeline diagram + live 7d/30d counts (lib/email-catalog.ts is the source of truth)
    • /admin/system — stack, monorepo, DB counts, lot inventory, worker queues
    • /admin/audit global timeline
    • /admin-docs-check slash command + ⚠️ KEEP IN SYNC headers on the curated files Wave 5 (622e4dd):
    • /admin/jobs (BullMQ queue depth + recent failures)
    • /admin/integrations (calendar tokens, Stripe Connect, webhook health)
    • /admin/db-stats (12-week per-table growth sparklines)
    • /admin/health (Stripe + Resend + redis + db latency probes)
    • VPS docker-prune cron (Monday 04:00 UTC, ~/docker-prune.sh)

    — Lot 14 — auth polish + growth content

    • "Stay signed in for 30 days" checkbox on login — when unchecked, the action re-writes the session cookie with a 24h max-age
    • ?ref=<slug> middleware capture into a 30-day first-touch cookie; signup action resolves + stamps User.referredBy + User.referredAt Batch 4:
    • /for/sales-teams + /for/consultants + /for/educators (use-case landing pages with hand-tuned copy)
    • /blog index + first MDX post (hello-caledee)
    • Dynamic OG image per event type via next/og at /u/[slug]/[eventSlug]/opengraph-image
    • Per-host analytics injection: User.ga4MeasurementId / metaPixelId / gtmContainerId, validated by strict regex; render canonical official snippets via <HostTrackingScripts>. Branding settings UI extended.

    — Lot 16 — webhooks v2 + Teams + rate-limit headers + OpenAPI

    • WebhookEvent enum: booking_payment_succeeded + booking_no_show now fire end-to-end. routing_form_submitted reserved (needs non-booking dispatch shape; deferred).
    • WebhookKind.teams — Adaptive Card reshape, auto-detect *.webhook.office.com
    • /api/v2/* responses get X-RateLimit-Limit / Remaining / Reset headers via applyRateLimitHeaders(); 429 also includes Retry-After. Batch 3:
    • /api/docs/openapi.json — hand-curated OpenAPI 3.1 spec
    • /api/docs — Swagger UI via swagger-ui-dist CDN, try-it-out enabled Batch 7:
    • /admin/audit/export CSV download (bounded 90d / 10k rows)

    — Lot 17 — perf scaffolds

    • next.config images.remotePatterns + 1h cache TTL (next/image works on host-uploaded logos)
    • serverExternalPackages tightening — otpauth + qrcode out of client bundle
    • scripts/k6/{public-booking,slots,availability-v2}.js + README

    — Lot 15 — collective calendar fan-out

    • syncBookingToExternalCalendars writes a calendar event on every pool member's connected calendar for collective bookings
    • BookingExternalEvent.userId column + index — cancel path now groups by owning user so a member's bad token can't block the host's delete Migrations applied since v0.20.0:
    • 20260520164444_lot14_referral_columns
    • 20260520164944_lot16_webhook_v2_events_and_teams
    • 20260520175322_lot14_tracking_ids
    • 20260520180530_lot15_external_event_user_id
    • 20260520160147_lot13_announcement_and_feature_flag (Wave 2)
    • 20260520160828_lot13_admin_audit_log (Wave 3)
  21. Version 0.20.0

    Six remaining roadmap items shipped in one push: 2FA login, admin backoffice, in-app notifications, email preferences, re-engagement emails, per-event-type schedule + per-weekday notice + collective events, Slack + Zapier docs, i18n Phase 2.

    — Lot 14 — Retention (final)

    • 2FA TOTP — full auth integration. /settings/security enrolment flow (QR code via qrcode, otpauth lib for codes, 10 bcrypt'd recovery codes surfaced once). Authorize() in the credentials provider throws 2FA_REQUIRED / 2FA_INVALID / ACCOUNT_SUSPENDED; loginAction maps those to friendly copy. Recovery codes accept 8-char hex; on match, the matching code is burned.
    • In-app notification bell in the sidebar (notif-bell.tsx, server component, unread coral dot). New /notifications page lists last 200 events newest-first, marks all read as a side-effect on visit, deep-links via payload.href. lib/notifications.ts exposes notify(userId, kind, payload) (best-effort). Hooked into booking create/cancel/reschedule.
    • Email preference center (Settings → Notifications): one toggle per category (reminders / follow-ups / payment receipts / no-show / weekly digest / re-engagement). Stored as User.emailPrefs JSON, defaults to all-on. shouldSend() gates each enqueue.
    • Re-engagement email: BullMQ daily fanout at 10 UTC; targets users created 14d+ ago with no bookings, no reengagementSentAt, not suspended (cap 200/day). Locale-aware HTML, stamps reengagementSentAt to prevent re-send.

    — Lot 15 — Power features (final)

    • Per-event-type availability schedule. EventType.availability ScheduleId is honored in the form (radio between "Default" and each named schedule), persisted on create + update, copied on duplicate. loadSchedulingContext() already used it; UI now matches.
    • Min notice per weekday (EventType.minNoticeMinByWeekday Int[]): optional 7-element override. Form surfaces 7 inputs under a toggle. computeSlots() reads per-weekday for the slot's host-tz day, falls back to minNoticeMin otherwise. 2 new vitest cases.
    • Collective events (EventTypeKind.collective): every host pool member must be free. computeBookableSlots() fans loadBusyAndCounts across the pool and merges the busy ranges; the schedule itself stays anchored to the event owner.

    — Lot 13 — Admin backoffice (scaffold)

    • /admin/* gated by User.isAdmin via requireAdmin() (redirect to /dashboard for non-admins, /login for guests).
    • Overview (/admin): user count + DAU approx + suspended count + bookings counters; MRR/churn stubbed pending Stripe ingest.
    • Users (/admin/users): paginated table with email/slug search, joined/event/booking counts, status chip. Detail page (/admin/users/[id]) with suspend/unsuspend (Server Actions in actions.ts). Suspended users get blocked by the credentials provider with ACCOUNT_SUSPENDED.
    • Feature flags (/admin/flags): read-only mirror of env vars (MAINTENANCE_MODE, SIGNUPS_DISABLED). Toggles still happen at deploy time — no hot-toggle to keep behaviour auditable.

    — Lot 16 — Integrations

    • Slack outbound webhooks (WebhookKind.slack): host picks "Slack — Incoming Webhook" in the webhook form; we reshape the payload to {text: "..."} and skip the HMAC signature header (Slack URLs are themselves the bearer secret). Auto-detect: if the URL is hooks.slack.com/..., the kind is forced to slack even when the host left the dropdown on Generic.
    • Zapier / Make docs page at /docs/integrations/zapier. No Zapier app yet — the trigger is the standard webhook. Sample payload step-by-step + a pointer to the signature-verify recipe.

    — Lot 18 Phase 2 — URL prefix routing

    • Middleware accepts /fr/... and /es/... as aliases for the underlying route. The handler rewrites the path, pins the caledee-locale cookie on the response, and lets the existing cookie-driven locale resolution take over. Default locale (en) stays at the bare path; no /en/ is ever emitted.
    • Language switcher now navigates to the prefixed URL instead of reloading in place.
    • Public booking pages declare alternates.languages for en / fr / es / x-default so Google + Bing serve the right language. Migration: 20260520123918_v0_20_admin_2fa_notifs_prefs_weekday, 20260520131011_v0_20_collective_event_kind.

    — CI

    • Deploy:staging is now manual. .gitlab-ci.yml gates the staging deploy behind a manual click in the pipeline view; validate runs on every push. Was burning the free 400-min/month shared-runner quota on every automatic staging push. Long-term fix: self-hosted runner on the Hostinger VPS.

    — Lot 15 — Power features (partial)

    • Profile preferences (User.dateFormat / timeFormat / country / showHostTimezone): new selects + checkboxes in Settings → Profile.
    • Single-use event-type links (EventType.singleUse): when toggled on, createBooking() auto-flips isActive: false after the first successful booking. New "Single use" toggle in the event-type form.
    • Show host timezone alongside invitee tz on /u/[slug]/[eventSlug]: driven by User.showHostTimezone. The tz meta-row appends · hôte en {tz}.
    • Book on behalf (/bookings/new): host-only manual booking form. Pick event type + datetime-local (rendered in host tz) + duration + invitee details. Calls the standard createBooking() so emails + reminders fire normally.
    • Multiple availability schedules on /availability: new SchedulesSwitcher lists schedules + create / rename / delete / set-default. ?id=<scheduleId> query picks the editor target. Deleting reassigns dependent event types back to the default.

    — Lot 14 — Retention (partial)

    • Magic link login (/login): collapsible "Email me a sign-in link" block. Auth.js Nodemailer provider with sendVerificationRequest pushing through our sendMail(). 10-minute token expiry. Ink+coral HTML email inline.
    • Weekly digest email (BullMQ scheduler + repeatable job): every Monday 09:00 UTC, the email worker fans out one digest.weekly job per opted-in host (User.emailDigest=true, default true). Past-7 / next-7 row tables with status dots; empty-window digests auto-skipped. New emails/weekly-digest.tsx React Email template.
    • 2FA TOTP — schema columns added (User.twoFactorSecret + twoFactorEnabledAt) but the login-flow integration is deferred — touching the auth path in one go without a full e2e pass is too risky. Migration: 20260520115410_lot14_15_profile_prefs_2fa_singleuse.
  22. Version 0.18.1

    Closes the six remaining Lot 12 items.

    — Lot 12 — final

    • Settings tabbed layout: /settings redirects to /settings/profile; new /settings/{profile,security,notifications,branding,billing,danger} sub-routes share an (app)/settings/layout.tsx shell with a left-rail nav (coral tick on the active tab; "Danger zone" rendered in red). Each card moved to its natural tab — Profile keeps the display-name / bio / locale / slug / calendar-integrations block, Security holds the SecurityActions card + access tokens + webhooks + a placeholder "Password & 2FA" card, Notifications is a stub, Branding holds the BrandingForm, Billing still lives at /settings/billing, Danger holds the unified export-and-delete card.
    • Event types — reorder UI: new ReorderControls + RowReorderButtons (↑↓ arrows per row when in edit mode). The pre-existing reorderEventTypesAction is the persistence layer. Optimistic state — click "Save order" to commit, "Cancel" to revert.
    • Starter template gallery at /event-types/new: four pre-canned templates (Quick intro 15m / Discovery 30m / Support 45m / Workshop 60m) surfaced as cards above the form. Picking one navigates to ?template=<key> and the form renders with the template's values as initial. The gallery hides once a template is picked so the page reads as a single create flow.
    • /bookings — Today/Tomorrow grouping: rows are now grouped under per-day mono eyebrows ("— Today", "— Tomorrow", "— Yesterday", or a short locale-formatted date). Active day key is coral; other days are ink. Computed in the host's timezone.
    • Batch cancel on /bookings upcoming tab: per-row checkboxes (BatchCancelRow), a sticky bottom toolbar (BatchCancelToolbar) that appears once at least one row is checked, type-CANCEL confirm prompt, per-row failure list. Cap 100 IDs per submit; ownership-filtered server-side; emails + webhooks fire normally per cancellation.
    • Public profile /u/[slug]: host avatar enlarged from 64px to 72px mobile / 96px desktop, with the larger initials size matching.
  23. Version 0.18.0

    A single tag closing everything shipped since v0.10.0: the Lot 10.1 punch-list, all of Lot 11 (v1 compliance & launch polish), most of Lot 12 (settings/bookings UX), part of Lot 14 (marketing expansion), Lot 17 (PWA shell), the multi-phase visual upgrade (May 2026), and Lot 18 (i18n EN/FR/ES Phase 1). Full play-by-play in the session logs: vault/sessions/2026-05-19-.md and vault/sessions/2026-05-20-.md.

    — Visual upgrade — May 2026 (Phases 1 + 2 + 3)

    • Replaced the slate + cyan brand palette with coral + ink + cyan: coral (#F26A4B) as the single accent moment per surface; warm-paper ink as the default neutral; cyan demoted to system/info/links.
    • New time-as-typography signature: oversized tabular numerals for every meeting time, duration, price, and section number. JetBrains Mono for "system speech" (timestamps, URLs, tokens).
    • Brand assets: 3-bar Caledee mark (components/icons/caledee/mark.tsx), 2px coral hairline tick (components/icons/caledee/tick.tsx), and the wordmark+mark Logo (components/logo.tsx). Favicon + Apple icon + OG card generated by next/og from app/icon.tsx, app/apple-icon.tsx, app/opengraph-image.tsx.
    • Fixed a site-wide dark-mode bug: darkMode: ["class", '[data-theme="dark"]'] in Tailwind v3 replaces the .dark selector instead of adding to it, silently breaking every dark: variant. Reverted to plain "class".
    • Rebuilt the marketing surfaces (home, pricing, features, docs index), the public booking surfaces (/u/[slug], /u/[slug]/[eventSlug], /c/[token], /r/[token], /reconfirm/[token], BookingFlow internals), the auth + onboarding + verify shells, and the dashboard hero. UI primitives + 64-file slate→ink + brand→coral sweep underpinning the rest.
    • All transactional emails redesigned around the same tokens — shared apps/web/emails/_styles.ts module; templates expose an optional time={day, time} prop to surface the typography signature.
    • Vault design system documented at vault/design/system.md. Brief for the next Claude Design round (emails v2) at docs/design-brief-emails-2026-05-20.md.

    — Lot 10.1 — closing the loose ends from Lot 10

    • Added: Resend transactional email wired on both staging and prod. DKIM (resend._domainkey.caledee.com), SPF on the send subdomain, DMARC (p=none monitor mode). API key piped via SSH stdin into per-env .env files. EMAIL_FROM aligned to noreply@caledee.com (DKIM-aligned). Inbound: OVH MX Plan free with 1 000 redirections.
    • Added: GitLab CI deploy:prod master-branch flow. Switched the rule from the never-existing main branch to master. Bug-fixed the deploy script to git reset --hard $CI_COMMIT_SHA so a deploy can't drift if a newer commit lands mid-flight.
    • Added: Sentry source-map upload pipeline (code path wired, activation gated on a CI token). Dockerfile + compose + CI forward SENTRY_AUTH_TOKEN / SENTRY_ORG / SENTRY_PROJECT as build args; the Sentry wrapper skips upload when the token is empty.
    • Added: infra/update-versions.sh — probes /api/health on both envs and prints the live BUILD_SHA + status. Reconciliation helper for vault/infra/versions.md.

    — Lot 11 — v1 compliance & launch polish (first wave)

    • Added: Privacy policy (/privacy) and Terms of service (/terms) MDX pages tailored to Caledee (GDPR + French law, real sub-processors, real retention windows). Footer legal links wired.
    • Added: Signup consent checkbox — required HTML5 + Zod-revalidated server-side. The OAuth buttons display an equivalent consent notice ("continuing means you accept…") since a redirect can't gate on client state.
    • Added: Settings → Danger zone — account deletion (GDPR Article 17). Re-validates the password for password-having accounts, blocks the delete when the user owns an organisation, cancels their pending sent invitations, audit-logs before the destructive op, signs out.
    • Added: Branded not-found.tsx (global 404, static + auth-free so it serves even during DB outages) and error.tsx (global 500, shows the Sentry-correlated digest as a "Reference" string for support).
    • Added: loading.tsx skeletons for /dashboard and /bookings — mirror the real page layout so the swap on data arrival doesn't shift content.
    • Added: SEO bricks — app/sitemap.ts (12 indexable marketing + docs pages; user profiles excluded for privacy), app/robots.ts (root allowed; authed app + /api + token routes disallowed), root metadata extended with OG + Twitter card + title template.
    • Added: A11y skip-to-main link in the root layout (sr-only, visible on focus, lands on <main id="main"> of each sub-layout).

    — Operations

    • Tagged v0.10.0 (Lot 10). New ADR-0008 records the Hostinger-VPS + cohabiting-Traefik + GitLab-CI hosting decision.
    • New vault/infra/ directory (9 files) plus vault/people/{nassim,mehdi} capture the shared-VPS context.

    — Lot 11 — v1 compliance & launch polish (second wave, 2026-05-20)

    • Added: Cookie banner — minimal GDPR informational dismiss-once banner. Caledee currently sets only essential cookies; the banner doesn't gate anything and persists the consent in localStorage.
    • Added: Maintenance mode flag (MAINTENANCE_MODE=1). Read at middleware-request time, rewrites GETs to /maintenance and 503s any mutation. Allowlist exempts /api/health, /_next/*, Sentry tunnel, and the favicon/OG segments.
    • Added: Log out of all devices in /settings. Implemented via a new User.sessionVersion column stamped into every JWT at sign-in; the Auth.js jwt callback rejects tokens whose v claim is below the DB value, instantly invalidating every prior session.
    • Added: Post-onboarding checklist on the dashboard. 5 steps (account / connect calendar / set availability / share link / first booking) with a coral progress bar; auto-hides at 5/5.
    • Added: Payment-receipt email to the invitee after a Stripe payment_intent.succeeded. Enqueue is idempotent (only fires when the Payment row actually flips to paid). Template surfaces the amount as a second time-as-typography moment + the Stripe reference in mono.
    • Added: No-show email to the host when they mark a booking as no-show on /bookings. Strike-through time block, link to the filtered bookings list. Unmarking is silent.
    • Added: Redis-backed rate limiter (atomic INCR+EXPIRE via Lua), falls back to the in-memory bucket if Redis is unreachable. limit() and consumeByKey() are now async; legacy sync consume() is memory-only.
    • Added: GDPR Article 20 data export. New DataExport model; Settings → Export my data queues the user's full row set into a single JSON payload, emails a one-time download link that expires in 24h; /api/data-export/[token] streams the JSON exactly once.

    — Lot 12 — Settings + bookings UX

    • Added on /bookings: search by invitee name/email, filter by event type, date-range (from/to), Export CSV (RFC 4180, capped at 1000 rows), load-more pagination (50/chunk, total count surfaced as a mono chip), filter chips with a one-click "clear".
    • Added on /event-types: Duplicate action (clones name/locations/ questions, inactive by default, picks a free -copy(-N) slug), archive/unarchive actions (soft-delete via deletedAt), manual reorder via new EventType.position, "bookings this month" counter on each card.
    • Added: Welcome message / bio field (280 char, plain text). Editable in Settings → Profile, rendered as a paragraph under the host name on /u/[slug].

    — Lot 14 — Marketing expansion

    • Added: /changelog — reads CHANGELOG.md from the repo root at build time and renders the H2 release sections + H3 sub-headings + bullets.
    • Added: /about — three principles + tech stack as a labelled <dl> + CTA, FR copy.
    • Added: /status — server-fetches /api/health and renders the per-check state (db, redis) with a green/red dot per row. No client polling.
    • Sitemap + footer updated to surface the three new pages.

    — Lot 17 — PWA shell

    • Added: app/manifest.ts → /manifest.webmanifest. Standalone display, ink background, coral theme color, FR locale; icons reuse the existing app/icon.tsx + app/apple-icon.tsx.

    — Lot 18 — Multilingual (EN / FR / ES, cookie-based v1, 2026-05-20)

    • New apps/web/i18n/ module: config.ts (locales, normalize, Accept-Language parser, cookie name), resolve.ts (server-only resolution — cookie → host pref → header → default), messages.ts (dynamic JSON loader + t() builder + {name} interpolation).
    • next-intl v4 installed (will power the URL-prefix Phase 2; v1 doesn't consume the router APIs yet).
    • Prisma migration 20260520_lot18_locale_columns: new columns User.preferredLocale, Booking.locale, Invitee.locale (all nullable, varchar 8). Translation files
    • apps/web/messages/{en,fr,es}/ with 6 namespaces each: common, booking, cancel, reschedule, reconfirm, email. 100% coverage across all three locales for shipped surfaces. FR translations match the design-tour copy that already shipped; ES is fresh translation (machine-assisted, flagged for native review in docs/i18n-review-es.md next session). Emails (every template, fully localized)
    • lib/email-jobs.tsx: pickLocale() helper picks the right locale per audience (invitee → Invitee.locale → Booking.locale → host pref → default; host → host pref → default). Builders are async now and call buildT(loadMessages(locale, "email")).
    • Booking lifecycle (created, canceled, rescheduled), reminder, follow-up, payment receipt, no-show, org invitation: all read their locale-specific messages and use Intl.DateTimeFormat(locale) for dates.
    • Intl.RelativeTimeFormat powers the reminder's "in X hours / minutes" string in the resolved locale.
    • Plain-HTML emails (lib/email-verification.ts, lib/password-reset.ts, lib/data-export.ts) call resolveLocale() from the request context and inline the translated strings. Booking capture
    • POST /api/bookings: resolves locale (cookie → host preferredLocale → Accept-Language), persists on both Booking.locale and Invitee.locale.
    • Reschedule: carries the original locale onto the new row. Stripe
    • createCheckoutUrl({ locale }) — passes locale to stripe.checkout.sessions.create, falling back to "auto". Wired through /api/billing/start and /api/billing/checkout callers. UI
    • components/language-switcher.tsx: 3-pill EN / FR / ES selector, writes caledee-locale cookie + reloads. Mounted in the marketing header in place of the static "FR · EN · ES" decorative chip.
    • Settings → Profile: new "Preferred language" select; persists to User.preferredLocale.
    • <html lang="…"> now driven by the resolved locale from the root layout (await resolveLocale()).
    • Open Graph metadata: declares EN as primary, FR + ES as alternateLocale.
    • Cancel page (/c/[token]) + reconfirm page (/reconfirm/[token]): fully translated using namespace JSON. Cancel-form client component carries its own inline 3-locale copy table. Phase 2 follow-up (not in this commit)
    • URL prefix routing (/fr/..., /es/...) — requires moving every route under app/[locale]/. Documented in roadmap. Cookie-based v1 is forward-compatible: when Phase 2 lands, the cookie + URL prefix cooperate, with URL taking precedence (already coded in resolve.ts).
    • Marketing pages (home, pricing, features, docs, changelog, about, status, legal MDX) still use their FR/EN-mix copy from earlier commits. Extraction into messages is incremental.
    • App dashboard ((app)/*) stays EN until a dedicated dashboard i18n pass.

    — Known follow-ups (still TODO)

    • OAuth prod redirect URIs (Google / Microsoft / Zoom) — Lot 10.1 step 2.
    • Stripe live keys + webhook on prod — Lot 10.1 step 3.
    • Better Stack uptime monitor — Lot 10.1 step 4.
    • Sentry source-map upload activation (waiting on the GitLab CI token).
    • Hostinger MCP token rotation (overdue — leaked in chat 2026-05-19).
    • Lot 11 tail items still open: maintenance-mode admin toggle, reCAPTCHA v3 on the public booking form, CSRF audit of every server action, visible focus-ring audit, color contrast audit.
    • Lot 12 settings tabs (/settings flat scroll → tabbed layout), batch cancel on /bookings, drag-handle reorder UI on /event-types.
    • Lot 18 (i18n EN/FR/ES) — full plan documented in the roadmap.
  24. Version 0.10.0

    Lot 10: Production deployment

    caledee.com is live. Two-environment setup (prod + staging) on a single Hostinger VPS in Paris, cohabiting with a pre-existing n8n + Traefik stack via Docker labels — no Coolify, no Railway, no second reverse proxy. GitLab CI auto-deploys staging on push; prod is gated behind a manual button. See ADR-0008 for the decision and vault/sessions/2026-05-19-lot-10-production-deploy.md for the full play-by-play including all the gotchas.

    — Other changes

    • Added: VPS hardening — dedicated caledee Linux user in the docker group (key-only login), 4 GB swap with vm.swappiness=10, fail2ban with the SSH jail enabled, sshd drop-in (PermitRootLogin prohibit-password, PasswordAuthentication no, MaxAuthTries 4, LoginGraceTime 30s).
    • Added: 10 OVH DNS records — apex + staging + www on both .com and .fr (A + AAAA each), TTL 300 s during launch. Also the four Resend records (DKIM, MX send, SPF send, DMARC) added in Lot 10.1.
    • Added: Multi-stage apps/web/Dockerfile (pnpm-aware, runs prisma generate + next build, prunes dev deps, runs as non-root with tini). Build injects BUILD_SHA so /api/health reports the live commit. --ignore-scripts on install dodges the Prisma postinstall before the schema is copied; dummy env vars in the builder stage dodge Zod's runtime env validation during next build.
    • Added: infra/docker-compose.{prod,staging}.yml — postgres + redis + one-shot migrate + web + worker. Both stacks join the existing root_default Traefik network. Per-env labels route caledee.com, www.caledee.com, and staging.caledee.com; a redirectregex middleware 308-redirects caledee.fr → caledee.com.
    • Added: apps/web/app/api/health/route.ts — liveness probe returning JSON with status, build SHA, DB + Redis ping. Used by Docker healthchecks and external uptime monitors.
    • Added: GitLab CI (.gitlab-ci.yml) — validate (typecheck + lint + unit tests) on every push and MR; deploy:staging auto on push to staging; deploy:prod as a manual gate on master. Both deploys SSH into the VPS, do git fetch + reset --hard + docker compose up -d --build, then assert /api/health.build matches the commit being deployed.
    • Added: vault/infra/ (9 docs) — VPS specs, n8n cohabitation plan, security baseline, access channels, OVH DNS records, live versions table, observability plan, daily-ops runbook.
    • Tagged v0.10.0.
  25. Lot 9: Public docs site

    MDX-driven /docs under the marketing shell.

    — Other changes

    • Added: @next/mdx integration with pageExtensions extended to .mdx. mdx-components.tsx provides Tailwind-styled prose components.
    • Added: New docs pages — getting-started, embed, api, webhooks, routing-forms, payments.
    • Added: rehype-pretty-code (dual GitHub light/dark Shiki theme) for syntax highlighting; rehype-slug + rehype-autolink-headings add permalinks visible on hover.
    • Added: Client-side <DocsSidebar /> with usePathname active state + <DocsMobileNav /> (<select> for small screens).
    • Tagged v0.9.0.
  26. Lot 8: Event-type polish + analytics

    — Other changes

    • Added: Group event types — EventType.maxInvitees. A slot stays open until capacity is full; subsequent invitees attach to the existing booking instead of creating a new row. Scheduling engine filters not- yet-full group bookings out of the busy set.
    • Added: No-show marking — Booking.noShowAt + server action with a defence-in-depth guard rejecting toggles on future bookings.
    • Added: Invitee reconfirmation flow — Invitee.confirmToken (unique) confirmedAt. The reminder email links to /reconfirm/[token]; the page flips confirmedAt only when the booking is still active.
    • Added: Custom branding (Pro feature) — User.brandingColor (hex ^#[0-9a-fA-F]{6}$), User.brandingLogoUrl (HTTPS only, no obvious non-image extensions), User.hideFooter. Surfaces on the public booking page (logo or coloured-initials avatar) and the profile page. Settings → Branding form has live preview.
    • Added: 30-day analytics dashboard — booked / canceled / no-show counts + cancellation rate + top 3 event types by booking count.
    • Tagged v0.8.0.
  27. Lot 7: Booking payments + routing forms

    — Other changes

    • Added: Stripe Connect — host onboarding (Express account, redirect link, status sync via account.updated webhook). New User fields stripeConnectAccountId, stripeConnectChargesEnabled, stripeConnectPayoutsEnabled.
    • Added: Booking-time payments — EventType.requiresPayment, paymentAmountCents, paymentCurrency. Payment intent created on the connected account at booking time; refunded on host-initiated cancel.
    • Added: Routing forms — full feature: builder UI (/routing-forms/...), Prisma models (RoutingForm, RoutingFormField, RoutingFormRule, RoutingFormSubmission), public submission page /f/[slug], rule engine (lib/routing-forms.ts, first-match-wins, event_type / url actions). Server-side sanitizeRules blocks cross-user targetEventTypeId.
    • Tagged v0.7.0.
  28. Lot 6: Subscription billing

    — Other changes

    • Added: Stripe Customer Portal for upgrade / downgrade / cancel.
    • Added: Subscription model on User — plan, status, current period end, Stripe customer + subscription IDs.
    • Added: 14-day trial on Pro / Team.
    • Added: canUse(feature, user) central plan gate. Features enumerated in lib/billing.ts.
    • Added: /settings/billing page mirroring Calendly's compare-plans card.
    • Added: Stripe Tax for EU VAT.
    • Added: Marketing pricing-page CTAs wired to /signup?plan=pro|team.
    • Tagged v0.6.0.
  29. Lot 5: Embed widget + public API

    — Other changes

    • Added: @calee/embed vanilla-JS widget — inline iframe, popup, floating badge. Prefill + UTM + postMessage events.
    • Added: Chromeless booking-page layout via ?embed=1.
    • Added: Public REST API v2 — /api/v2/me, /event-types, /availability, /bookings. JSON over HTTPS, authenticated by personal access tokens with scoped permissions (read:profile, read:bookings, write:bookings, etc.).
    • Added: Outbound webhook subscriptions — HMAC-SHA256 signatures, exponential-backoff retries, auto-disable after 15 consecutive failures.
    • Tagged v0.5.0.
  30. Lot 4: Teams

    — Other changes

    • Added: Organization + OrganizationMember + OrganizationInvitation models. Roles: owner / admin / member.
    • Added: Email invitations with token verification + auto-accept on signup with the invited address.
    • Added: Collective and round-robin event types — host pool + distribution strategy (least-recently-booked).
    • Added: Admin-managed and shared event types.
    • Added: Org-scoped dashboards.
    • Tagged v0.4.0.
  31. Lot 2: Calendar & conferencing integrations + Marketing site

    Released alongside the marketing site so the public-facing pages (/, /pricing, /features, /docs) could direct sign-ups straight to the now-working calendar-connect flow.

    — Calendar integrations

    • Added: Google Calendar — OAuth with access_type=offline + prompt=consent, free/busy via freeBusy, event creation with Google Meet (conferenceData), event delete on cancel/reschedule, push notifications (events.watch + /api/webhooks/google-calendar, channel-token HMAC'd over AUTH_SECRET).
    • Added: Microsoft 365 / Outlook — OAuth on the /common authority with refresh-token rotation, Outlook free/busy via /me/calendar/getSchedule, event creation with isOnlineMeeting + onlineMeetingProvider=teamsForBusiness.
    • Added: Zoom (conferencing-only) — OAuth with HTTP Basic on the token endpoint, meetings created before the calendar event so the location field can carry the Zoom URL.
    • Added: Per-connection free/busy cache (90 s TTL, 1-hour day-bucket keys, invalidated by the Google push channel).
    • Added: Sign-in with Google / Microsoft via Auth.js PrismaAdapter alongside the JWT-session credentials provider (User.slug @default(cuid()) so the adapter can create rows; OAuth users land at /onboarding).
    • Added: AES-256-GCM encryption for stored OAuth tokens, keyed by ENCRYPTION_KEY.

    — App shell + UX polish (Calendly-inspired)

    • Added: Left-sidebar app shell replacing the top nav.
    • Added: Real month-grid calendar on the booking page.
    • Added: Accordion event-type editor with state-summary lines.
    • Added: Standalone /integrations Discover / Manage page.
    • Added: Apply-to-all-other-days in the availability editor.
    • Added: Colored left-edge bar on event-type cards.
    • Added: Expanded booking row with private host notes + "Schedule invitee again" deep link.
    • Added: Holidays (whole-day blocks, beat any date override).

    — Marketing site

    • Added: route group (marketing) with shared layout (top nav + footer).
    • Added: / homepage (hero + 3 value props + how-it-works + pricing teaser + closing CTA).
    • Added: /pricing with 3 tier cards, yearly/monthly toggle, comparison table, and 5-question FAQ.
    • Added: /features with 9-card overview of what's shipped.
    • Added: /docs placeholder linking to repo docs and ADRs (real docs site lands in Lot 9).

    — Fixed

    • Bundling: BullMQ + ioredis kept out of the Next webpack server bundle via serverExternalPackages in next.config.mjs.
    • Workers moved out of instrumentation.ts into a standalone process (apps/web/scripts/worker.ts). Next 15.5's dev bundler trips on BullMQ's Node-only deps from instrumentation; the standalone process side-steps the bundler entirely.
    • lib/email-jobs.tsx got an explicit import * as React from "react" so the worker process's classic-JSX runtime has React in scope. Tagged v0.2.0.
  32. Version 0.3.0

    Lot 3: Workflows + reminders + follow-ups + SMS

    Reminders, follow-ups, and SMS — driven by a configurable Workflow model with per-event-type attachment. Booking emails now flow through a BullMQ queue + standalone worker process. Closes Lot 3.

    — Lot 3 — Workflows, reminders & follow-ups

    • Added: React Email templates (apps/web/emails/booking-notification.tsx, apps/web/emails/booking-reminder.tsx) replace the hand-rolled inline-HTML templates for the three booking notification kinds + the new reminder. Single component per kind serves both audiences via a buildVariant helper; plain-text alternate derived from the same props. (Slice 1 of Lot 3 — finally landing the migration deferred from Lot 1.)
    • Added: BullMQ email + reminders queues with in-process workers (apps/web/lib/queue.ts, apps/web/lib/start-workers.ts, apps/web/instrumentation.ts). Booking confirmation / cancel / reschedule emails now flow through the email queue + worker (5x concurrency, 5x retries with exponential backoff). The worker re-fetches the booking from the DB before rendering, so the email always reflects current state. Workers boot once per Node process via Next 15's instrumentation.ts hook, gated on NEXT_RUNTIME === 'nodejs' and CALEDEE_DISABLE_WORKERS !== '1' for serverless / dedicated-worker deployments. Playwright e2e polls Mailpit (instead of expecting synchronous delivery) since email delivery is now async.
    • Added: Reminder scheduling — enqueueReminder({bookingId, audience}, runAt) schedules a delayed BullMQ job; stable job IDs (reminder:{audience}:{bookingId}) make re-scheduling idempotent. The reminder worker defensively skips canceled bookings + bookings whose start time has already passed.
    • Added: Workflow model (Workflow + WorkflowStep + enums WorkflowTrigger { booking_created | booking_starts | booking_ends | booking_canceled }, WorkflowAction { email_reminder_invitee | email_reminder_host | email_followup_invitee }). Migration add_workflows. New apps/web/lib/workflows.ts resolves a host's active workflows into concrete enqueue / reminder jobs on booking-create + reschedule, and cancels the scheduled jobs on cancel/reschedule. Replaces the hardcoded 60-minute reminder lead from slice 2.
    • Added: Default "Reminders" workflow seeded for every user (one 60-min-before pre-meeting reminder to the invitee). The onboarding action calls ensureDefaultWorkflow(userId) (idempotent); the dev seed creates the same row for the demo host.
    • Added: /workflows page (read-only) — listing the host's workflows + each step described in plain English ("60 minutes before the start, email the invitee a reminder"). New sidebar entry between Bookings and Integrations.
    • Added: Workflow editor (Lot 3 slice 4) — /workflows/new to create a workflow, /workflows/[id] to edit one. Inline step editor with trigger/amount/unit (minutes / hours / days) / before-or-after / action selectors; live JSON.stringify(payload) posted to server actions (createWorkflowAction, updateWorkflowAction, deleteWorkflowAction, toggleWorkflowActiveAction) that validate via Zod and rewrite the WorkflowStep rows in a transaction. The seeded default is editable in place but protected from deletion (it auto-respawns via ensureDefaultWorkflow). List page gets a + New workflow CTA + per-row hover-elevate + Edit affordance.
    • Added: Per-event-type workflow attachment (Lot 3 slice 5) — new WorkflowEventType join table + migration. A workflow with no rows in the join table fires on every booking (preserves the seeded default's behavior); a workflow with one or more attachments fires only when the booking's event type matches. Server-validated via Zod (ownership-checked in validateOwnedEventTypeIds). The workflow editor gets a new Applies to card with a per-event-type checkbox grid. The list page shows "Applies to: every event type" or "Applies to: A, B, C." under each row. Engine: loadActiveSteps(hostId, eventTypeId) filters via an OR of "no attachments" / "attached to this event type"; applyWorkflowsForBookingCreated + cancelWorkflowsForBooking now take eventTypeId (sourced from Booking.eventTypeId at the call sites).
    • Added: Follow-up email template + distinct scheduled-job kinds (Lot 3 slice 6) — new apps/web/emails/booking-followup.tsx React Email template with "Thanks for meeting" copy and a "Book another time" CTA pointing at the host's public profile. The reminders queue's ReminderJob is now a discriminated union ("booking.reminder" | "booking.followup"); job IDs carry the kind so reminder + follow-up for the same booking + audience don't collide. lib/workflows.ts reminderKindForAction routes email_followup_invitee to the follow-up kind, every other email action to the reminder kind. Worker (processReminderJob) picks the right template per kind; cancelAllScheduledForBooking(bookingId) blanket-removes both kinds × both audiences on cancel/reschedule.
    • Added: SMS reminders via Twilio (Lot 3 slice 7) — new sms_reminder_invitee enum value on WorkflowAction + migration add_sms_action_and_optin that also adds Invitee.smsOptIn (default false). The reminders queue gets a third job kind "booking.sms-reminder". New apps/web/lib/twilio.ts does a single REST POST to Twilio with HTTP Basic auth; sending is gated on TWILIO_ACCOUNT_SID + TWILIO_AUTH_TOKEN + TWILIO_FROM_NUMBER being set AND the invitee having both a phone (Invitee.textReminderNumber, repurposed from the existing field) and smsOptIn === true. Public booking page gets a + Text reminders (SMS, optional) reveal with the phone input + consent checkbox; backend Zod accepts both fields, persists them on the invitee row, and carries them through reschedules. Workflow editor's action dropdown now includes "Text the invitee a reminder (SMS)". Setup walk-through: docs/lot3-twilio-setup.md.
    • _Coming next in Lot 3:_ workflow editor UI (create/edit/disable, add/remove steps), per-event-type workflow attachment, SMS action type via Twilio.

    — Lot 2 — Calendar & conferencing integrations (in progress)

    • Added: Google Calendar connect (foundation) — schema ConnectedCalendar + migration add_connected_calendar; AES-256-GCM secret encryption in apps/web/lib/crypto.ts (keyed by ENCRYPTION_KEY); raw-fetch Google OAuth client apps/web/lib/google-oauth.ts (buildAuthorizationUrl, exchangeCodeForTokens, refreshAccessToken, fetchUserInfo, revokeRefreshToken); apps/web/lib/connections.ts (list / save / disconnect, encrypts tokens at rest); GET /api/connections/google (CSRF state via VerificationToken oauth_state:google:) → Google → GET /api/connections/google/callback (validates state, exchanges code, upserts the row); /settings has a new Calendar integrations section with Connect / Disconnect controls + a flash for the result. Helpful "OAuth isn't configured" path if env vars are missing. Setup steps: docs/lot2-google-setup.md. .env.example updated.
    • Added: Google Calendar free/busy + event creation + Google Meet — DB: new BookingExternalEvent model (provider, calendarId, externalEventId, joinUrl); google_meet added to the LocationKind enum. Migration add_external_events_and_meet. lib/connections.ts getFreshAccessToken now genuinely refreshes via Google's token endpoint and persists the new access token (re-encrypted); on refresh failure (revoked refresh token, etc.) the connection is soft-disconnected and a clear ConnectionExpiredError is thrown. lib/google-calendar.ts — freeBusy, createEvent (with optional conferenceData.createRequest for Meet, sendUpdates=none so we don't double-email), deleteEvent (404/410 treated as success). computeBookableSlots now folds the host's Google busy ranges into the engine's busy[] (degrades gracefully — a failed API call means we may over-offer slots, not break booking). createBooking syncs the booking to the host's Google calendar (Meet link auto-attached when the event type's location is google_meet, then Booking.locationValue is updated to the join URL). cancelBooking and rescheduleBooking delete the external events. BookingExternalEvent rows track everything. Event-type form gains a Google Meet option in the location selector, disabled (with an inline note) until the host connects Google Calendar in Settings.
    • Added: Microsoft 365 / Outlook connect + free/busy + event creation + Microsoft Teams — DB: CalendarProvider enum extended to { google, microsoft }; microsoft_teams added to LocationKind. Migration add_microsoft_provider_and_teams. apps/web/lib/microsoft-oauth.ts — raw-fetch v2.0 client on the /common authority (buildAuthorizationUrl, exchangeCodeForTokens, refreshAccessToken (refresh-token rotation-aware), fetchUserInfo). Scopes: openid profile email offline_access User.Read Calendars.ReadWrite. apps/web/lib/microsoft-calendar.ts — getSchedule (Outlook free/busy via /me/calendar/getSchedule), createEvent (/me/events with isOnlineMeeting + onlineMeetingProvider=teamsForBusiness when a Teams meeting is requested), deleteEvent. apps/web/lib/connections.ts is now multi-provider — getFreshAccessToken dispatches on conn.provider (refreshes through the right endpoint, persists the rotated Microsoft refresh token); new saveMicrosoftConnection, getActiveMicrosoftConnection, getActiveConnections. ConnectionSummary.provider widened to the Prisma enum. computeBookableSlots also folds the host's Microsoft busy ranges into the engine's busy[] (same graceful degradation as Google). createBooking / rescheduleBooking / cancelBooking now fan out external-calendar sync + cleanup to both Google and Microsoft. Teams join URL persisted to Booking.locationValue for microsoft_teams bookings. GET /api/connections/microsoft (CSRF state via oauth_state:microsoft:) → Microsoft → GET /api/connections/microsoft/callback. /settings integrations section adds a Connect Microsoft 365 button (gated by isMicrosoftOAuthConfigured()); event-type form adds the Microsoft Teams location option (disabled until the host connects Microsoft 365). Setup steps: docs/lot2-microsoft-setup.md. .env.example updated with MICROSOFT_OAUTH_*.
    • Added: Google Calendar push notifications + per-connection free/busy cache — DB: ConnectedCalendar gains watchChannelId @unique, watchResourceId, watchExpiresAt, lastInvalidatedAt. Migration add_google_watch_fields. lib/google-calendar.ts — watchEvents (POST …/events/watch) and stopWatch (POST …/channels/stop). 7-day TTL. lib/connections.ts — subscribeToGoogleChanges(conn) / unsubscribeFromGoogleChanges(conn). Watch-channel token derived via HMAC-SHA256(AUTH_SECRET, channelId) so the webhook can verify without a per-row secret. pushWebhookUrl() returns null when APP_URL isn't HTTPS — push is no-op'd in dev unless you set up ngrok (see docs/lot2-google-setup.md). lib/free-busy-cache.ts — in-memory per-connection cache (90s TTL, 1-hour day-bucket keys). Read in scheduling.ts before hitting Google or Microsoft; written after a successful fetch. Invalidated by the webhook via invalidateConnection(connId); also auto-invalidated when lastInvalidatedAt > cachedAt. POST /api/webhooks/google-calendar — verifies X-Goog-Channel-Token (constant-time compare), looks up the connection by watchChannelId, calls invalidateConnection, stamps lastInvalidatedAt. Ignores the initial sync ping. Google connect callback now subscribes; disconnect now stops the watch before revoking. Setup doc adds an ngrok recipe for exercising push notifications in dev.
    • Added: Zoom OAuth + Zoom conferencing location — DB: CalendarProvider enum extended to { google, microsoft, zoom }; zoom added to LocationKind. Migration add_zoom_provider_and_location. apps/web/lib/zoom-oauth.ts — auth-code OAuth client (Zoom's token endpoint requires HTTP Basic auth, not body params); refresh-token rotation handled. Scopes: meeting:write, user:read. apps/web/lib/zoom-meetings.ts — POST /v2/users/me/meetings (type: 2 scheduled) and DELETE /v2/meetings/{id}. apps/web/lib/connections.ts adds saveZoomConnection, getActiveZoomConnection, and a zoom branch in getFreshAccessToken (refresh-token rotation). apps/web/lib/bookings.ts — when locationKind === "zoom", creates the Zoom meeting first so the Google/Microsoft calendar events get the Zoom URL in their location field. Cancel/reschedule deletes the Zoom meeting too. GET /api/connections/zoom + GET /api/connections/zoom/callback (CSRF state via oauth_state:zoom:). /settings integrations section adds a Connect Zoom button (gated by isZoomOAuthConfigured()); event-type form adds the Zoom location option (disabled until the host connects Zoom). Setup steps: docs/lot2-zoom-setup.md. .env.example updated with ZOOM_OAUTH_*.
    • Added: Sign in with Google / Microsoft (Auth.js Prisma adapter) — apps/web/lib/auth.ts now mounts PrismaAdapter(prisma) alongside the JWT-session strategy. Credentials provider unchanged; Google and Microsoft OAuth providers are wired in conditionally (the same env vars as the host-side connect flow), with allowDangerousEmailAccountLinking: true so an existing credentials user can also sign in with their OAuth provider on the same email. User.slug now has @default(cuid()) so the adapter's prisma.user.create succeeds for fresh OAuth signups; users pick a friendly slug at /onboarding (the (app) layout already gates on onboardedAt). events.createUser copies name → displayName so the dashboard greeting reads naturally before the user visits Settings. Login + signup pages get "Continue with Google" / "Continue with Microsoft" buttons (server-action signIn(provider, { redirectTo })), only rendered when the provider is configured. The signup page sends OAuth users to /onboarding; the login page to /dashboard (the layout still redirects to /onboarding if needed).
    • _Lot 2 substantially complete._ The Calendly-inspired UX backlog from reference/calendly-ui/analysis.md is next.

    — Lot 2 UX backlog — started

    • Added: Left sidebar app shell — the (app) layout swaps its top horizontal nav for a left sidebar (Dashboard / Event types / Availability / Bookings / Integrations / Settings) with the user identity + sign-out anchored at the bottom. Active route is highlighted via usePathname(). Mobile collapses to a compact top bar with quick links + the brand.
    • Added: /integrations Discover/Manage page — standalone integrations hub. "Connected" lists current calendar + conferencing connections with per-row Disconnect; "Discover" surfaces provider cards (Google Calendar, Microsoft 365, Zoom) for anything not yet connected, with per-card setup-doc hints when the provider isn't configured on the server. All OAuth callback routes now redirect to /integrations?integration=... instead of /settings?integration=.... The Settings page keeps a compact summary card linking to /integrations.
    • Added: Real month-grid calendar on the booking page — replaces the available-dates list with a 7-column grid (Mon–Sun, week-starts-Monday) for the month being viewed. Days without slots are dimmed and disabled; the selected day is filled; today gets a subtle ring. Prev/next month buttons fetch a new 31-day window (the API cap); the calendar anchors on the month containing the first available date on first load. The time list moves to a single column on the right, headed by the selected day.
    • Added: Accordion event-type editor with state summaries — the five sections of the event-type form (Basics / Duration & scheduling rules / Location / Questions / Visibility) are now collapsible cards with a one-line summary visible while closed (e.g. Duration & scheduling rules: "30 min · 15 min increments · 60 min notice · 60 days ahead"). Basics is open by default; the rest start collapsed.
    • Added: Quick wins — "Apply to all other days" link on each weekday row in the availability editor (copies its hours into the other six weekdays in one click); colored left-edge bar on event-type list cards (uses each event type's color, replaces the small dot).
    • Added: Expanded booking row + private host notes + Schedule-invitee-again — DB: new Booking.hostNotes column. Migration add_booking_host_notes. Bookings list rows get an inline Details toggle revealing full invitee contact, location, custom-question answers, the invitee's own notes, and a private host-notes textarea (server action saveBookingHostNotesAction). Schedule {first name} again → deep-links into the public booking page with ?prefillName= and ?prefillEmail= set; the booking page reads them and forwards as a new optional prefill prop to BookingFlow, which seeds the name + email inputs.
    • Fixed (security, pre-v0.2.0): auth-tokens.ts: consumeToken now validates prefix + expiry before deleting the row. Previously, submitting a token of the wrong purpose (e.g., a password-reset token to an OAuth callback) hard-deleted that row as a side effect — a DoS vector against legitimate password-reset / email-verify flows. Mismatched purposes are now a clean no-op. lib/auth.ts: new signIn callback refuses to auto-link an OAuth sign-in to an existing credentials user whose email has not been verified. Closes the account-squat path opened by allowDangerousEmailAccountLinking: true (attacker registers victim@x.com with their own password, victim later signs in with Google/Microsoft → without this gate, the OAuth identity would have linked to the attacker's row). webhooks/google-calendar: per-channel rate limit (60/min) + X-Goog-Message-Number monotonic dedupe (new ConnectedCalendar.lastWatchMessage column + migration add_watch_message_number). An exfiltrated (channelId, token) pair can no longer drive unbounded DB writes, and the same notification can't be replayed against us. lib/connections.ts: per-connection in-memory mutex around the refresh-and-persist round-trip. Microsoft + Zoom rotate refresh tokens on every refresh response, so two concurrent requests would otherwise risk losing the newer token. The mutex re-reads the row inside its scope so a request that was waiting picks up the just-refreshed token instead of triggering a duplicate refresh. Single-instance only; Redis-lock noted for the multi-instance migration. lib/connections.ts: watch-channel HMAC now domain-separates AUTH_SECRET via a caledee.v1.google-watch-channel: prefix on the input. Leakage of a channel token no longer yields oracle data against the JWT-signing key. Pre-existing watch channels will fail token verification once after upgrading and must be re-opened by disconnecting + reconnecting Google in /integrations. lib/bookings.ts: external-calendar/conferencing error logs now scrub email-shaped strings + cap at 300 chars (scrubLogError). Provider error bodies routinely echo invitee email + answers; this keeps them out of operator logs.
    • Added: Holidays (whole-day blocks) — DB: new Holiday model — userId + date + name, unique per (userId, date). Migration add_holidays. loadSchedulingContext now layers each holiday on top of the engine schedule as an empty intervals array, so a holiday date forces the day closed even if a date_override said otherwise. /availability gets a Holidays card with the current list + an Add form (server actions addHolidayAction / removeHolidayAction).
  33. Version 0.1.0

    Lot 1: Scheduling MVP

    A stranger can book a meeting on your public page, get a confirmation, reschedule it, cancel it; you can configure your handle, timezone, working hours, event types (with custom questions and locations), and manage every booking from your dashboard. The Lot-1 DoD is met: full pipeline (typecheck / lint / unit / build) green, Playwright happy-path e2e green.

    — Foundations

    • Brand & design charter (charte/) — simple, clean ("épuré") visual language; refined Tailwind preset to match.
    • Added: ADR-0005 (timezone library: Luxon), ADR-0006 (double-booking guard: Postgres EXCLUDE GiST constraint), ADR-0007 (email delivery: BullMQ + React Email; Mailpit in dev, Resend in prod).
    • Added: project Claude skills in .claude/skills/ (caledee-context, new-adr, prisma-change).
    • Added: scheduling engine in packages/core — domain types, Zod schemas, computeSlots (tz/DST-aware) with a 23-case Vitest suite.
    • Changed: all instant DB columns are now timestamptz (@db.Timestamptz(6)); added User.onboardedAt; EventType defaults tuned (color, rollingDays). Migrations: add_user_onboarded_at, use_timestamptz.
    • Added: the Booking_no_overlap_per_host exclusion constraint (Postgres EXCLUDE/GiST over tstzrange, btree_gist) — migration add_booking_overlap_exclude (ADR-0006).
    • Added: authentication — Auth.js v5 with the Credentials provider + JWT sessions (no DB adapter yet); /signup, /login (server actions), sign-out, an auth()-guarded /dashboard shell, and an /onboarding step (pick handle + timezone → creates a default Mon–Fri 09:00–17:00 availability schedule + a starter 30-minute event type). Minimal UI primitives in apps/web/components/ui styled to the charter (to migrate to @calee/ui). Loads the monorepo-root .env from next.config.mjs; Inter via next/font.
    • Added: the public booking loop, end to end — GET /api/event-types/:id/slots?from&to[&tz][&duration] — calls computeSlots against the DB (effective availability schedule + active bookings padded by their buffers + per-day counts). /u/{slug} (public profile listing active, non-secret event types) and /u/{slug}/{eventSlug} (booking page: available-dates picker → time list in the invitee's detected timezone → details form with custom questions / guests / notes / location → confirm → confirmation screen with Google/Outlook/.ics add-to-calendar links). POST /api/bookings — Zod-validated; slot re-check + the Booking_no_overlap_per_host exclusion constraint as the race guard (409 on conflict); creates Booking + Invitee (CSPRNG cancel/reschedule tokens) + Answers atomically; sends confirmation emails (invitee + host) via SMTP — Mailpit in dev, Resend in prod (plain HTML for now; not via the BullMQ queue yet). /c/{cancelToken} — cancel with an optional reason (host notified). /r/{rescheduleToken} + POST /api/bookings/reschedule — reschedule = cancel-old + create-new (linked via rescheduledFromId) in one transaction; both reuse the booking-flow UI. Dashboard now lists the user's event types (with public links) and upcoming bookings. pnpm db:seed creates a demo host (demo@caledee.local / password123, /u/demo/30min). Verified end-to-end: slots → book → emails → reschedule → cancel-old → emails → double-book 409.
    • Added: the host management UI — /settings — edit display name, booking handle (slug; uniqueness-checked, with a "this changes your URLs" warning), timezone, and password. /availability — edit the default schedule's timezone, weekly hours (per-day HH:mm ranges, add/remove), and date overrides (custom hours or "unavailable"); validated with the @calee/core schemas; saves by replacing the schedule's rules in a transaction. /event-types (list, with public links + active/secret badges), /event-types/new, /event-types/[id] — full create/edit form: name, link slug (unique per user), description, color, single or multiple durations, location (ask-invitee / fixed place-or-link / host-phone / invitee-phone), scheduling rules (start-time increments, buffers before/after, minimum notice, daily limit, booking window: rolling N days / fixed dates / indefinite), custom questions (short/long text, email, phone, pick-one, pick-many — with required toggle), active/secret flags; soft-delete; "View" link. Nav updated; the dashboard's event-types section links to it.
    • Added: /bookings — host bookings page with Upcoming / Past / Canceled tabs (?status=), per-row Cancel (server action, optional reason; emails the invitee) and Reschedule (opens /r/{rescheduleToken}). Nav updated.
    • Added: timezone picker on the booking page — a curated list of common IANA zones (the browser-detected zone is added if it's missing); the slot list re-fetches on change; locked to a read-only label when EventType.allowedTz is set.
    • Added: rate limiting on the public endpoints and the auth server actions (apps/web/lib/rate-limit.ts — an in-memory token bucket keyed by IP; single-instance only, with a Redis migration path noted in docs/rules/security.md). Wired into GET /api/event-types/:id/slots (120/min), POST /api/bookings (10/min), POST /api/bookings/reschedule (10/min), the signup action (5/h), and the login action (10/5min) — exceeding the limit returns 429 with a Retry-After-ish message.
    • Added: email verification — sendVerifyEmail fires from signup, the link goes to /verify/{token} which consumes the VerificationToken row and sets User.emailVerified. A small amber banner on every authed page invites unverified users to "Resend" (server action; idempotent). The demo seed is pre-verified. Not gating features yet — informational only for Lot 1.
    • Added: password reset — /forgot-password always returns "if that email is registered, a link is on its way" to avoid user enumeration (rate-limited); email with a one-hour token; /reset-password/{token} form peeks the token before showing it, consumes it on submit, hashes + saves the new password, then redirects to /login?reset=1 (shows a "Password updated" flash). New lib/auth-tokens.ts shares the VerificationToken table between verify- and reset-flows via prefixed identifiers (verify: vs pwd:), so a token issued for one purpose can't be used for the other. "Forgot password?" link added to /login.
    • Playwright e2e happy path — apps/web/e2e/booking-happy-path.spec.ts drives the full flow in one test: sign up a fresh host → onboard → assert the dashboard → as an invitee in a separate context, open /u/{slug}/30min → pick a slot → fill name/email/location → confirm → assert the confirmation screen + the two confirmation emails in Mailpit → reschedule via the link → cancel via the link → as the host, verify the booking shows in /bookings?status=canceled. Runs in ~11s. CI already wires playwright install chromium + pnpm test:e2e.

    — Deferred (intentionally — to land in their natural lot)

    • Emails-via-BullMQ-queue + React Email templates (ADR-0007 update on 2026-05-13): for v0.1.0 emails are plain HTML sent directly. The queue's real value is scheduled delivery for reminders/follow-ups in Lot 3, where the migration will happen alongside the workflows engine.
    • Auth.js Prisma adapter + OAuth login (Google / Microsoft) — lands in Lot 2 with the calendar-connect work.
  34. Version 0.0.0

    Lot 0: project scaffold

    — Added

    • pnpm-workspaces monorepo (+ Turborepo): apps/web (Next.js 15, App Router, TypeScript), packages/core (pure scheduling engine + domain types + Zod — placeholder), packages/db (Prisma + PostgreSQL), packages/ui (shared shadcn/ui components + Tailwind preset), packages/config (shared eslint/prettier/tailwind presets, incl. a lint guard keeping packages/core pure).
    • Prisma schema (User/Auth, AvailabilitySchedule + Rule, EventType + CustomQuestion + Location, Booking + Invitee + Answer, EmailLog) and the initial migration 20260512125827_init; @calee/db Prisma client singleton.
    • Next.js app skeleton: home page, root layout, /api/auth/[...nextauth] placeholder; lib/{env,db,queue,auth}.ts (Zod-validated env, BullMQ email queue, Auth.js placeholder); next.config, Tailwind/PostCSS, Vitest, Playwright + an e2e smoke test.
    • Tooling: root scripts via Turborepo, tsconfig.base.json (strict), ESLint flat config, Prettier, .editorconfig, .nvmrc (Node 20), .env.example, docker-compose.dev.yml (Postgres + Redis + Mailpit), GitHub Actions CI (typecheck/lint/test/build + Playwright e2e + audit).
    • Docs: docs/ROADMAP.md (8 lots), docs/ARCHITECTURE.md, docs/rules/ (engineering, security, git-and-pr, agents-and-memory), ADRs 0001–0004 (stack, monorepo layout, Obsidian memory, time/timezones), docs/specs/lot1-mvp.md, docs/machine-setup.md (install log).
    • Knowledge base: vault/ — repo-local Obsidian vault (project context, domain notes on the scheduling engine / data model / Calendly parity / glossary / deployment, decisions index, dated session logs, people).
    • Reference: reference/calendly-docs/ — curated offline notes on Calendly's API v2 and product behaviour (parity targets).
    • Claude tooling: .claude/agents/ (db-schema-architect, scheduling-engine-dev, calendly-parity-researcher, code-reviewer), .claude/commands/ (/session-log, /hot-update, /lot-status), .claude/settings.json.

    — Notes

    • Auth.js v5 wiring is deferred to Lot 1 (the beta dependency graph needs version pinning); lib/auth.ts and /api/auth/[...nextauth] are placeholders.
    • NODE_ENV is intentionally absent from .env/.env.example — forcing it breaks next build.
Changelog — Caledee · Caledee