From 0807b6ae8c8821e9d4d170083a46a5ba5269db05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 15:38:32 +0000 Subject: [PATCH 1/3] Fix reconcile tests rotting past their hardcoded fixture dates Fixtures used September 2026 dates; reconcile is scoped to future bookings, so once those dates elapsed the rows were skipped as history and four tests failed. Derive the fixture year from the current date instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP8VHeDuMhdC6865MY5VfR --- tests/backend/reconcile.test.js | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/tests/backend/reconcile.test.js b/tests/backend/reconcile.test.js index 8bc7096..bab4406 100644 --- a/tests/backend/reconcile.test.js +++ b/tests/backend/reconcile.test.js @@ -14,6 +14,9 @@ const { loadBackend } = require('./gas-harness'); const { createRunner } = require('./_runner'); const CAL = 'cal-primary'; +// Fixture year kept in the future: reconcile only touches upcoming bookings, so +// hardcoded dates silently turn into skipped "past" rows once they elapse. +const FY = String(new Date().getFullYear() + 2); const FILES = ['Booking.gs', 'Calendar.gs', 'Reconcile.gs']; const CONFIG = { @@ -45,11 +48,11 @@ const r = createRunner('reconcileBookings (sheet/calendar drift healer)'); r.test('cancels confirmed rows whose calendar event is missing, keeps live ones', function () { const ctx = makeCtx([ - { token: 't-live', eventId: 'evt-live', status: 'confirmed', startTime: '2026-09-01T14:00:00.000Z', endTime: '2026-09-01T14:15:00.000Z' }, - { token: 't-gone', eventId: 'evt-gone', status: 'confirmed', startTime: '2026-09-01T15:00:00.000Z', endTime: '2026-09-01T15:15:00.000Z' }, - { token: 't-canc', eventId: 'evt-canc', status: 'cancelled', startTime: '2026-09-01T17:00:00.000Z', endTime: '2026-09-01T17:15:00.000Z', cancelledAt: '2026-08-01T00:00:00.000Z' }, + { token: 't-live', eventId: 'evt-live', status: 'confirmed', startTime: FY + '-09-01T14:00:00.000Z', endTime: FY + '-09-01T14:15:00.000Z' }, + { token: 't-gone', eventId: 'evt-gone', status: 'confirmed', startTime: FY + '-09-01T15:00:00.000Z', endTime: FY + '-09-01T15:15:00.000Z' }, + { token: 't-canc', eventId: 'evt-canc', status: 'cancelled', startTime: FY + '-09-01T17:00:00.000Z', endTime: FY + '-09-01T17:15:00.000Z', cancelledAt: '2026-08-01T00:00:00.000Z' }, ]); - seedActiveEvent(ctx, 'evt-live', '2026-09-01T14:00:00.000Z', '2026-09-01T14:15:00.000Z'); + seedActiveEvent(ctx, 'evt-live', FY + '-09-01T14:00:00.000Z', FY + '-09-01T14:15:00.000Z'); // 'evt-gone' is never seeded -> absent from getEvents -> gone. const res = ctx.reconcileBookings(); @@ -65,10 +68,10 @@ r.test('cancels confirmed rows whose calendar event is missing, keeps live ones' r.test('does not mistake a DIFFERENT event in the same window for the booking', function () { const ctx = makeCtx([ - { token: 't-gone', eventId: 'evt-gone', status: 'confirmed', startTime: '2026-09-05T14:00:00.000Z', endTime: '2026-09-05T14:15:00.000Z' }, + { token: 't-gone', eventId: 'evt-gone', status: 'confirmed', startTime: FY + '-09-05T14:00:00.000Z', endTime: FY + '-09-05T14:15:00.000Z' }, ]); // A different meeting overlaps the window, but it is NOT this booking's event. - seedActiveEvent(ctx, 'some-other-event', '2026-09-05T14:00:00.000Z', '2026-09-05T14:15:00.000Z'); + seedActiveEvent(ctx, 'some-other-event', FY + '-09-05T14:00:00.000Z', FY + '-09-05T14:15:00.000Z'); const res = ctx.reconcileBookings(); assert.strictEqual(res.reconciled, 1, 'match must be by event id, not merely by time overlap'); assert.strictEqual(ctx.BookingStore.getByToken('t-gone').status, 'cancelled'); @@ -76,11 +79,11 @@ r.test('does not mistake a DIFFERENT event in the same window for the booking', r.test('is a no-op when every confirmed booking still has its event', function () { const ctx = makeCtx([ - { token: 't1', eventId: 'evt-1', status: 'confirmed', startTime: '2026-09-02T14:00:00.000Z', endTime: '2026-09-02T14:15:00.000Z' }, - { token: 't2', eventId: 'evt-2', status: 'confirmed', startTime: '2026-09-02T15:00:00.000Z', endTime: '2026-09-02T15:15:00.000Z' }, + { token: 't1', eventId: 'evt-1', status: 'confirmed', startTime: FY + '-09-02T14:00:00.000Z', endTime: FY + '-09-02T14:15:00.000Z' }, + { token: 't2', eventId: 'evt-2', status: 'confirmed', startTime: FY + '-09-02T15:00:00.000Z', endTime: FY + '-09-02T15:15:00.000Z' }, ]); - seedActiveEvent(ctx, 'evt-1', '2026-09-02T14:00:00.000Z', '2026-09-02T14:15:00.000Z'); - seedActiveEvent(ctx, 'evt-2', '2026-09-02T15:00:00.000Z', '2026-09-02T15:15:00.000Z'); + seedActiveEvent(ctx, 'evt-1', FY + '-09-02T14:00:00.000Z', FY + '-09-02T14:15:00.000Z'); + seedActiveEvent(ctx, 'evt-2', FY + '-09-02T15:00:00.000Z', FY + '-09-02T15:15:00.000Z'); const res = ctx.reconcileBookings(); assert.strictEqual(res.success, true); assert.strictEqual(res.reconciled, 0); @@ -103,7 +106,7 @@ r.test('never cancels a PAST booking, even if its event is gone (history is left r.test('never cancels a confirmed row with a blank eventId (unverifiable => keep)', function () { const ctx = makeCtx([ - { token: 't-blank', eventId: '', status: 'confirmed', startTime: '2026-09-03T14:00:00.000Z', endTime: '2026-09-03T14:15:00.000Z' }, + { token: 't-blank', eventId: '', status: 'confirmed', startTime: FY + '-09-03T14:00:00.000Z', endTime: FY + '-09-03T14:15:00.000Z' }, ]); const res = ctx.reconcileBookings(); assert.strictEqual(res.success, true); @@ -114,7 +117,7 @@ r.test('never cancels a confirmed row with a blank eventId (unverifiable => keep r.test('never cancels a freshly-created row even if its event is not yet visible', function () { const nowIso = new Date().toISOString(); const ctx = makeCtx([ - { token: 't-fresh', eventId: 'evt-lagging', status: 'confirmed', createdAt: nowIso, startTime: '2026-09-03T15:00:00.000Z', endTime: '2026-09-03T15:15:00.000Z' }, + { token: 't-fresh', eventId: 'evt-lagging', status: 'confirmed', createdAt: nowIso, startTime: FY + '-09-03T15:00:00.000Z', endTime: FY + '-09-03T15:15:00.000Z' }, ]); // 'evt-lagging' is not seeded -> absent from getEvents (models propagation lag), // but the freshness grace must keep the row anyway. @@ -154,7 +157,7 @@ r.test('doGet reconcile rejects a missing or wrong key', function () { r.test('doGet reconcile with the right key runs the sweep', function () { const ctx = makeFullCtx([ - { token: 't-gone', eventId: 'evt-gone', status: 'confirmed', startTime: '2026-09-10T14:00:00.000Z', endTime: '2026-09-10T14:15:00.000Z' }, + { token: 't-gone', eventId: 'evt-gone', status: 'confirmed', startTime: FY + '-09-10T14:00:00.000Z', endTime: FY + '-09-10T14:15:00.000Z' }, ], 'secret-key'); const res = doGetJson(ctx, { action: 'reconcile', key: 'secret-key' }); assert.strictEqual(res.success, true, JSON.stringify(res)); @@ -164,7 +167,7 @@ r.test('doGet reconcile with the right key runs the sweep', function () { r.test('doPost reconcile (key in body) is the secure scheduled-job path', function () { const ctx = makeFullCtx([ - { token: 't-gone', eventId: 'evt-gone', status: 'confirmed', startTime: '2026-09-11T14:00:00.000Z', endTime: '2026-09-11T14:15:00.000Z' }, + { token: 't-gone', eventId: 'evt-gone', status: 'confirmed', startTime: FY + '-09-11T14:00:00.000Z', endTime: FY + '-09-11T14:15:00.000Z' }, ], 'secret-key'); // wrong/missing key rejected... assert.strictEqual(doPostJson(ctx, { action: 'reconcile' }).error, 'UNAUTHORIZED'); From c1299cc652f64c5cc88899476ca8f6dab1663b3e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 15:47:26 +0000 Subject: [PATCH 2/3] Require email verification before a booking is finalized Non-legitimate bookings (throwaway names/addresses, keyboard-mash notes, often booked then cancelled within a minute) were landing directly on the calendar, notifying the owner and making the owner's account send invites to arbitrary addresses. They looked human, so the honeypot and rate limits didn't catch them. createBooking now stores a pending row and emails the visitor a confirm link (confirm.html). Nothing reaches the calendar, the invitee or the owner until the visitor clicks 'Confirm booking'; confirmBooking then re-checks availability under the lock, creates the event and sends the normal confirmation emails. Pending rows don't hold the slot (first to verify wins), so unverified requests can't squat times. Links expire after VERIFY_LINK_MINUTES (60); REQUIRE_EMAIL_VERIFICATION=false restores immediate booking. Confirmation requires a click so mail link-scanners can't verify on the visitor's behalf. Also: share event creation / response shaping between create, confirm and reschedule; extract the token-page message renderer (PageMessage); fix cancel/confirm pages rendering raw ISO dates (TimezoneUtil was never initialised there) and misaligned action buttons. Refs #1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SP8VHeDuMhdC6865MY5VfR --- README.md | 2 + backend/Booking.gs | 2 + backend/Code.gs | 244 ++++++++++++++--------- backend/Config.gs | 6 + backend/Email.gs | 42 ++++ cancel.html | 1 + confirm.html | 41 ++++ css/styles.css | 26 ++- docs/architecture.md | 10 +- docs/configuration.md | 2 + docs/user-guide.md | 16 +- js/api-client.js | 5 + js/app.js | 27 ++- js/cancel.js | 30 +-- js/confirm.js | 147 ++++++++++++++ js/page-message.js | 43 ++++ tests/backend/email-verification.test.js | 213 ++++++++++++++++++++ tests/backend/gas-harness.js | 1 + tests/backend/handlers.test.js | 4 + tests/e2e/booking-flow.test.js | 27 ++- tests/frontend/booking-submit.test.js | 17 ++ tests/frontend/cancel-page.test.js | 1 + tests/frontend/confirm-page.test.js | 105 ++++++++++ 23 files changed, 878 insertions(+), 134 deletions(-) create mode 100644 confirm.html create mode 100644 js/confirm.js create mode 100644 js/page-message.js create mode 100644 tests/backend/email-verification.test.js create mode 100644 tests/frontend/confirm-page.test.js diff --git a/README.md b/README.md index f74a65d..bfc4643 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A self-hosted scheduling application inspired by YouCanBook.me and Calendly, run - **Meeting type selection** — configurable via YAML (office hours, check-ins, project meetings, custom durations) - **Calendar availability** — reads availability windows from Google Calendar, checks configured calendars for conflicts - **Booking form** — collects visitor details, supports in-person and virtual formats +- **Email verification** — bookings are finalized only after the visitor clicks a link sent to their address, which filters out non-legitimate bookings - **Email notifications** — sends confirmation, cancellation, and reschedule emails with iCal attachments - **Cancel & reschedule** — secure token-based links in every confirmation email - **Zero cost** — GitHub Pages (free) + Google Apps Script (free) + Google Sheets (free) @@ -39,6 +40,7 @@ See the [full setup guide](https://cdl-scheduler.readthedocs.io/en/latest/setup. ``` index.html Main booking flow (4-step wizard) +confirm.html Email-verification page cancel.html Cancellation page reschedule.html Reschedule page config/ YAML configuration files diff --git a/backend/Booking.gs b/backend/Booking.gs index ae5a968..5d47be2 100644 --- a/backend/Booking.gs +++ b/backend/Booking.gs @@ -103,6 +103,8 @@ var BookingStore = (function () { /** * Find a confirmed booking whose time range overlaps [startISO, endISO]. + * Only 'confirmed' rows count: a 'pending' row (awaiting email verification) + * deliberately does not hold its slot, so unverified requests can't squat times. * * This is the authoritative, immediately-consistent double-booking guard: * unlike the calendar (read back through the eventually-consistent diff --git a/backend/Code.gs b/backend/Code.gs index ff423da..1e9a3cb 100644 --- a/backend/Code.gs +++ b/backend/Code.gs @@ -83,6 +83,8 @@ function doPost(e) { return handleGetAvailableSlots(requestData); case 'createBooking': return handleCreateBooking(requestData); + case 'confirmBooking': + return handleConfirmBooking(requestData); case 'cancelBooking': return handleCancelBooking(requestData); case 'getBooking': @@ -170,30 +172,11 @@ function handleCreateBooking(data) { return jsonResponse({ success: false, error: 'SLOT_TAKEN', message: 'This time slot is no longer available. Please select another time.' }); } - // Generate token first so it's available for the event description - var token = TokenService.generateToken(); - var pagesUrl = Config.get('GITHUB_PAGES_URL'); - data.token = token; - - // Create calendar event - var ownerName = Config.get('OWNER_NAME') || 'Jeremy'; - var ownerFirst = ownerName.split(' ')[0]; - var eventTitle = data.eventLabel - ? data.firstName + '/' + ownerFirst + ' ' + data.eventLabel - : data.firstName + '/' + ownerFirst + ': ' + data.meetingTypeName; - var description = buildEventDescription(data); - var calendar = CalendarApp.getCalendarById(Config.get('CALENDAR_ID')); - var event = calendar.createEvent(eventTitle, startDate, endDate, { - guests: data.email, - sendInvites: true, - location: data.location, - description: description, - }); var bookingRecord = { - token: token, + token: TokenService.generateToken(), tokenExpiresAt: TokenService.getExpiryDate(), - eventId: event.getId(), - status: 'confirmed', + eventId: '', + status: 'pending', meetingTypeId: data.meetingTypeId, meetingTypeName: data.meetingTypeName, eventLabel: data.eventLabel || '', @@ -211,22 +194,32 @@ function handleCreateBooking(data) { rescheduledTo: '', }; - BookingStore.create(bookingRecord); - - // Send emails - EmailService.sendBookingConfirmation(bookingRecord); + // Email verification: store a PENDING row and email the visitor a link to + // confirm. Nothing is put on the calendar, no invite goes out, and the owner + // isn't notified until the address is proven to belong to the booker — this + // is what stops throwaway/non-legitimate bookings (and stops the form being + // used to make the owner's account mail invites to arbitrary addresses). + // A pending row deliberately does NOT hold the slot (only 'confirmed' rows + // block in findOverlappingConfirmed), so unverified requests can't squat + // times; availability is re-checked when the link is confirmed. + if (verificationRequired()) { + BookingStore.create(bookingRecord); + EmailService.sendVerificationEmail(bookingRecord); + return jsonResponse({ + success: true, + pending: true, + booking: { + start: data.start, + end: data.end, + email: data.email, + verifyWithinMinutes: verifyLinkMinutes(), + }, + }); + } - return jsonResponse({ - success: true, - booking: { - token: token, - eventId: event.getId(), - start: data.start, - end: data.end, - cancelUrl: pagesUrl + '/cancel.html?token=' + token, - rescheduleUrl: pagesUrl + '/reschedule.html?token=' + token, - }, - }); + finalizeBooking(bookingRecord); + BookingStore.create(bookingRecord); + return jsonResponse({ success: true, booking: bookingResponse(bookingRecord) }); } finally { // Persist pending sheet writes BEFORE releasing the lock, so the next request // to take the lock sees this booking's row. Without the flush the row might @@ -237,6 +230,57 @@ function handleCreateBooking(data) { } } +/** + * Finalize a pending booking once the visitor clicks the link in their + * verification email (confirm.html POSTs this after an explicit button click, so + * email link-scanners can't confirm on the visitor's behalf). + * + * Idempotent: confirming an already-confirmed booking returns it again rather + * than creating a second event. + */ +function handleConfirmBooking(data) { + if (!data.token) { + return jsonResponse({ success: false, error: 'VALIDATION_ERROR', message: 'Token is required' }); + } + + var lock = LockService.getScriptLock(); + if (!lock.tryLock(10000)) { + return jsonResponse({ success: false, error: 'LOCK_TIMEOUT', message: 'Server is busy. Please try again in a moment.' }); + } + + try { + var booking = BookingStore.getByToken(data.token); + if (!booking) { + return jsonResponse({ success: false, error: 'NOT_FOUND', message: 'Booking not found' }); + } + if (booking.status === 'confirmed') { + return jsonResponse({ success: true, alreadyConfirmed: true, booking: bookingResponse(booking) }); + } + if (booking.status !== 'pending') { + return jsonResponse({ success: false, error: 'ALREADY_CANCELLED', message: 'This booking request is no longer active.' }); + } + if (isVerificationExpired(booking)) { + BookingStore.updateStatus(data.token, 'expired'); + return jsonResponse({ success: false, error: 'EXPIRED', message: 'This confirmation link has expired. Please book again.' }); + } + + // The slot was never held while pending, so re-run both availability checks + // exactly as createBooking does — someone else may have confirmed first. + if (BookingStore.findOverlappingConfirmed(booking.startTime, booking.endTime, null, isStaleGhost) || + !CalendarService.isRangeAvailable(new Date(booking.startTime), new Date(booking.endTime))) { + BookingStore.updateStatus(data.token, 'expired'); + return jsonResponse({ success: false, error: 'SLOT_TAKEN', message: 'Sorry, this time was booked by someone else before you confirmed. Please choose another time.' }); + } + + finalizeBooking(booking); + BookingStore.updateStatus(data.token, 'confirmed', { eventId: booking.eventId }); + return jsonResponse({ success: true, booking: bookingResponse(booking) }); + } finally { + try { SpreadsheetApp.flush(); } catch (e) { /* nothing pending */ } + lock.releaseLock(); + } +} + function handleCancelBooking(data) { if (!data.token) { return jsonResponse({ success: false, error: 'VALIDATION_ERROR', message: 'Token is required' }); @@ -257,6 +301,12 @@ function handleCancelBooking(data) { return jsonResponse({ success: false, error: 'ALREADY_CANCELLED', message: 'This booking has been rescheduled' }); } + if (booking.status === 'pending' || booking.status === 'expired') { + // Never finalized: no calendar event exists and nobody was notified. + BookingStore.updateStatus(data.token, 'cancelled', { cancelledAt: new Date().toISOString() }); + return jsonResponse({ success: true, message: 'Booking request cancelled' }); + } + // Delete calendar event try { var calendar = CalendarApp.getCalendarById(Config.get('CALENDAR_ID')); @@ -324,21 +374,6 @@ function handleRescheduleBooking(data) { return jsonResponse({ success: false, error: 'ALREADY_CANCELLED', message: 'This booking cannot be rescheduled' }); } - // Create new booking with reschedule - var newBookingData = { - meetingTypeId: oldBooking.meetingTypeId, - meetingTypeName: oldBooking.meetingTypeName || oldBooking.meetingTypeId, - start: data.newStart, - end: data.newEnd, - firstName: oldBooking.firstName, - lastName: oldBooking.lastName, - email: oldBooking.email, - format: oldBooking.format, - location: oldBooking.location, - purpose: oldBooking.purpose, - notes: oldBooking.notes, - }; - var lock = LockService.getScriptLock(); if (!lock.tryLock(10000)) { return jsonResponse({ success: false, error: 'LOCK_TIMEOUT', message: 'Server is busy. Please try again in a moment.' }); @@ -367,37 +402,11 @@ function handleRescheduleBooking(data) { Logger.log('Error deleting old event during reschedule: ' + err.message); } - // Generate new token first so it's available for the event description var newToken = TokenService.generateToken(); - var pagesUrl = Config.get('GITHUB_PAGES_URL'); - - // Create new calendar event with the new token in the description - var descData = { - token: newToken, - firstName: oldBooking.firstName, - lastName: oldBooking.lastName, - email: oldBooking.email, - purpose: oldBooking.purpose, - notes: oldBooking.notes, - }; - var ownerName = Config.get('OWNER_NAME') || 'Jeremy'; - var ownerFirst = ownerName.split(' ')[0]; - var eventTitle = oldBooking.eventLabel - ? oldBooking.firstName + '/' + ownerFirst + ' ' + oldBooking.eventLabel - : oldBooking.firstName + '/' + ownerFirst + ': ' + (oldBooking.meetingTypeName || oldBooking.meetingTypeId); - var description = buildEventDescription(descData); - var calendar = CalendarApp.getCalendarById(Config.get('CALENDAR_ID')); - var newEvent = calendar.createEvent(eventTitle, newStart, newEnd, { - guests: oldBooking.email, - sendInvites: true, - location: oldBooking.location, - description: description, - }); - var newBookingRecord = { token: newToken, tokenExpiresAt: TokenService.getExpiryDate(), - eventId: newEvent.getId(), + eventId: '', status: 'confirmed', meetingTypeId: oldBooking.meetingTypeId, meetingTypeName: oldBooking.meetingTypeName || oldBooking.meetingTypeId, @@ -415,6 +424,8 @@ function handleRescheduleBooking(data) { cancelledAt: '', rescheduledTo: '', }; + // The address was already verified when the original booking was confirmed. + newBookingRecord.eventId = createBookingEvent(newBookingRecord); BookingStore.create(newBookingRecord); BookingStore.updateStatus(data.oldToken, 'rescheduled', { rescheduledTo: newToken }); @@ -422,17 +433,7 @@ function handleRescheduleBooking(data) { // Send reschedule emails EmailService.sendRescheduleEmail(oldBooking, newBookingRecord); - return jsonResponse({ - success: true, - booking: { - token: newToken, - eventId: newEvent.getId(), - start: data.newStart, - end: data.newEnd, - cancelUrl: pagesUrl + '/cancel.html?token=' + newToken, - rescheduleUrl: pagesUrl + '/reschedule.html?token=' + newToken, - }, - }); + return jsonResponse({ success: true, booking: bookingResponse(newBookingRecord) }); } finally { // Persist pending sheet writes BEFORE releasing the lock, so the next request // to take the lock sees this booking's row. Without the flush the row might @@ -445,6 +446,67 @@ function handleRescheduleBooking(data) { // --- Helper Functions --- +/** Whether new bookings must be confirmed via an emailed link (default: yes). */ +function verificationRequired() { + return String(Config.get('REQUIRE_EMAIL_VERIFICATION') || 'true').toLowerCase() !== 'false'; +} + +function verifyLinkMinutes() { + return parseInt(Config.get('VERIFY_LINK_MINUTES'), 10) || 60; +} + +/** A pending booking whose confirmation link is older than VERIFY_LINK_MINUTES. */ +function isVerificationExpired(booking) { + var createdAt = new Date(booking.createdAt).getTime(); + if (isNaN(createdAt)) return true; // can't tell when it was requested -> don't honour it + return (Date.now() - createdAt) > verifyLinkMinutes() * 60 * 1000; +} + +/** + * Create the Google Calendar event (and native invite to the visitor) for a + * booking record. Returns the new event id. + */ +function createBookingEvent(record) { + var ownerName = Config.get('OWNER_NAME') || 'Jeremy'; + var ownerFirst = ownerName.split(' ')[0]; + var eventTitle = record.eventLabel + ? record.firstName + '/' + ownerFirst + ' ' + record.eventLabel + : record.firstName + '/' + ownerFirst + ': ' + (record.meetingTypeName || record.meetingTypeId); + var calendar = CalendarApp.getCalendarById(Config.get('CALENDAR_ID')); + var event = calendar.createEvent(eventTitle, new Date(record.startTime), new Date(record.endTime), { + guests: record.email, + sendInvites: true, + location: record.location, + description: buildEventDescription(record), + }); + return event.getId(); +} + +/** + * Turn a booking record into a real meeting: calendar event + invite, then the + * visitor confirmation and owner notification. Mutates `record` (eventId, + * status) — the caller persists it (create for a new row, updateStatus for a + * verified pending row). Call inside the script lock after availability checks. + */ +function finalizeBooking(record) { + record.eventId = createBookingEvent(record); + record.status = 'confirmed'; + EmailService.sendBookingConfirmation(record); +} + +/** Public shape of a confirmed booking returned to the frontend. */ +function bookingResponse(record) { + var pagesUrl = Config.get('GITHUB_PAGES_URL'); + return { + token: record.token, + eventId: record.eventId, + start: record.startTime, + end: record.endTime, + cancelUrl: pagesUrl + '/cancel.html?token=' + record.token, + rescheduleUrl: pagesUrl + '/reschedule.html?token=' + record.token, + }; +} + /** * Key-gated maintenance actions (reconcile, cleanup), shared by doGet and doPost. * The key is compared against CLEANUP_KEY. Prefer calling these via POST with the @@ -499,7 +561,7 @@ function rateBump(cache, key, limit, windowSec) { * @return {boolean} true if the request is allowed, false if it should be throttled. */ function rateLimitOk(action, requestData) { - var WRITE_ACTIONS = { createBooking: true, cancelBooking: true, rescheduleBooking: true }; + var WRITE_ACTIONS = { createBooking: true, confirmBooking: true, cancelBooking: true, rescheduleBooking: true }; var cache = CacheService.getScriptCache(); var clientId = requestData && requestData.clientId; var cid = clientId ? String(clientId).slice(0, 64) : 'anon'; diff --git a/backend/Config.gs b/backend/Config.gs index 892fae7..95fee31 100644 --- a/backend/Config.gs +++ b/backend/Config.gs @@ -30,6 +30,12 @@ var Config = (function () { // just-created event may not yet be visible via getEventById, so a fresh row // must never be mistaken for a "ghost" (deleted-out-of-band) row. GHOST_GRACE_MINUTES: '10', + // Email verification: a new booking is only a pending request until the + // visitor clicks the link emailed to them. Until then there is no calendar + // event, no invite, no owner notification, and the slot is NOT held. Set to + // 'false' to finalize bookings immediately (the pre-verification behaviour). + REQUIRE_EMAIL_VERIFICATION: 'true', + VERIFY_LINK_MINUTES: '60', // how long a confirmation link stays valid }; function get(key) { diff --git a/backend/Email.gs b/backend/Email.gs index baf5755..c1e1754 100644 --- a/backend/Email.gs +++ b/backend/Email.gs @@ -79,6 +79,47 @@ var EmailService = (function () { } } + /** + * Send the visitor a link to confirm a pending booking request. This is the + * ONLY email a new request triggers: no invite, no owner notification, no + * cancel/reschedule links — those follow once the visitor proves they own the + * address by clicking through (see handleConfirmBooking). + */ + function sendVerificationEmail(booking) { + var ownerName = Config.get('OWNER_NAME'); + var pagesUrl = Config.get('GITHUB_PAGES_URL'); + var minutes = parseInt(Config.get('VERIFY_LINK_MINUTES'), 10) || 60; + + var startDate = new Date(booking.startTime); + var endDate = new Date(booking.endTime); + var dateStr = formatDateForEmail(startDate); + var timeStr = formatTimeRange(startDate, endDate); + + var subject = 'Action needed: confirm your ' + booking.meetingTypeId + ' booking on ' + dateStr; + var body = 'Hi ' + booking.firstName + ',\n\n' + + 'Please confirm your meeting request by opening this link and clicking "Confirm booking":\n\n' + + pagesUrl + '/confirm.html?token=' + booking.token + '\n\n' + + 'Requested meeting:\n' + + ' Type: ' + booking.meetingTypeId + '\n' + + ' Date: ' + dateStr + '\n' + + ' Time: ' + timeStr + '\n\n' + + 'Your booking is NOT final until you confirm, and the time is not held for you in the meantime. ' + + 'This link expires in ' + minutes + ' minutes.\n\n' + + 'If you didn\'t request this meeting, ignore this email and nothing will be booked.' + + '\n\nBest,\n' + ownerName; + + try { + MailApp.sendEmail({ + to: booking.email, + subject: subject, + body: body, + name: ownerName, + }); + } catch (e) { + Logger.log('Failed to send verification email: ' + e.message); + } + } + /** * Send cancellation emails to both visitor and owner. */ @@ -288,6 +329,7 @@ var EmailService = (function () { return { sendBookingConfirmation: sendBookingConfirmation, + sendVerificationEmail: sendVerificationEmail, sendCancellationEmail: sendCancellationEmail, sendRescheduleEmail: sendRescheduleEmail, buildICalContent: buildICalContent, diff --git a/cancel.html b/cancel.html index 76199a4..ffcc920 100644 --- a/cancel.html +++ b/cancel.html @@ -30,6 +30,7 @@

Cancel Booking

+ + + +
+
+

Confirm Booking

+
+ +
+
+

Loading booking details…

+
+
+ + + + +
+ + + + + + + + + diff --git a/css/styles.css b/css/styles.css index 508c695..cb9e800 100644 --- a/css/styles.css +++ b/css/styles.css @@ -519,6 +519,17 @@ a:hover { margin-top: 8px; } +/* Action buttons beside link-buttons (confirm/cancel pages): size to the label + and match the link-button metrics so the row lines up. */ +.confirmation-links .btn-primary { + width: auto; + margin-top: 0; + padding: 10px 24px; + font-size: 13px; + letter-spacing: 1.5px; + line-height: inherit; /*