OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-booking commits / 68718d6

Google Calendar: OAuth routes, server wiring, dashboard UI
Routes: auth-url, callback, status, calendars, disconnect Server: conditional GoogleCalendarClient init from GCAL_CLIENT_ID env Dashboard: calendar section in settings (connect/select/disconnect) 34/34 tests, 174 assertions, no regression. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
68718d6845629391a82535bd1f4cbf3be60613a3
by plankatron <fleeky@gmail.com> · 2026-03-22T19:38:21.000Z

Changed files (3) +287 -0

lib/routes/gcal.js +191 -0
……@@ -0,0 +1,191 @@
1/**
2 * Google Calendar API routes — OAuth flow + calendar management.
3 */
4
5import crypto from 'bare-crypto'
6import { json, notFound, redirect } from '../response.js'
7import { requireAuth } from './auth-helpers.js'
8import { readJson } from '../response.js'
9
10export function registerGcalRoutes (router, server) {
11 const gcal = server.gcal
12
13 // Get OAuth2 authorization URL
14 router.get('/api/gcal/auth-url', async (req, res) => {
15 const auth = await requireAuth(req, server, 'write')
16 const providerId = await getProviderIdForUser(server, auth.userId)
17 if (!providerId) return json(res, { error: 'No provider profile' }, 403)
18
19 // Generate CSRF nonce
20 const nonce = crypto.randomBytes(16).toString('hex')
21 const state = Buffer.from(JSON.stringify({ providerId, nonce })).toString('base64url')
22
23 // Store nonce for validation (5-min TTL)
24 await server.db.put(`gcal:state:${nonce}`, {
25 providerId,
26 createdAt: Date.now()
27 })
28
29 // Build redirect URI from request host
30 const host = req.headers.host || 'localhost:4050'
31 const protocol = req.headers['x-forwarded-proto'] || 'http'
32 const redirectUri = `${protocol}://${host}${server.pathPrefix}/api/gcal/callback`
33
34 const url = gcal.getAuthUrl(redirectUri, state)
35 json(res, { url })
36 })
37
38 // OAuth2 callback — Google redirects here
39 router.get('/api/gcal/callback', async (req, res) => {
40 const url = new URL('http://x' + req.url)
41 const code = url.searchParams.get('code')
42 const stateParam = url.searchParams.get('state')
43 const error = url.searchParams.get('error')
44
45 if (error) {
46 return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=${error}`)
47 }
48
49 if (!code || !stateParam) {
50 return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=missing_params`)
51 }
52
53 // Decode and validate state
54 let state
55 try {
56 state = JSON.parse(Buffer.from(stateParam, 'base64url').toString())
57 } catch {
58 return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=invalid_state`)
59 }
60
61 const { providerId, nonce } = state
62
63 // Validate CSRF nonce
64 const nonceEntry = await server.db.get(`gcal:state:${nonce}`)
65 if (!nonceEntry) {
66 return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=expired_state`)
67 }
68
69 // Check nonce age (5 min max)
70 if (Date.now() - nonceEntry.value.createdAt > 5 * 60 * 1000) {
71 await server.db.del(`gcal:state:${nonce}`)
72 return redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=expired_state`)
73 }
74
75 // Consume nonce
76 await server.db.del(`gcal:state:${nonce}`)
77
78 // Exchange code for tokens
79 const host = req.headers.host || 'localhost:4050'
80 const protocol = req.headers['x-forwarded-proto'] || 'http'
81 const redirectUri = `${protocol}://${host}${server.pathPrefix}/api/gcal/callback`
82
83 try {
84 const tokens = await gcal.exchangeCode(code, redirectUri)
85
86 // Store tokens
87 await server.db.put(`gcal:provider:${providerId}`, {
88 connected: true,
89 accessToken: tokens.accessToken,
90 refreshToken: tokens.refreshToken,
91 tokenExpiry: Date.now() + (tokens.expiresIn * 1000),
92 calendarIds: ['primary'],
93 connectedAt: Date.now()
94 })
95
96 redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=connected`)
97 } catch (err) {
98 console.error('[GCal] Token exchange failed:', err.message)
99 redirect(res, `${server.pathPrefix}/dashboard/settings?gcal=error&reason=token_exchange_failed`)
100 }
101 })
102
103 // Get connection status
104 router.get('/api/gcal/status', async (req, res) => {
105 const auth = await requireAuth(req, server, 'write')
106 const providerId = await getProviderIdForUser(server, auth.userId)
107 if (!providerId) return json(res, { connected: false })
108
109 const entry = await server.db.get(`gcal:provider:${providerId}`)
110 if (!entry || !entry.value.connected) {
111 return json(res, { connected: false })
112 }
113
114 json(res, {
115 connected: true,
116 calendarIds: entry.value.calendarIds || ['primary'],
117 connectedAt: entry.value.connectedAt
118 })
119 })
120
121 // List available calendars from Google
122 router.get('/api/gcal/calendars', async (req, res) => {
123 const auth = await requireAuth(req, server, 'write')
124 const providerId = await getProviderIdForUser(server, auth.userId)
125 if (!providerId) return json(res, { error: 'No provider profile' }, 403)
126
127 const accessToken = await gcal.getValidToken(server.db, providerId)
128 if (!accessToken) return json(res, { error: 'Not connected to Google Calendar' }, 400)
129
130 try {
131 const calendars = await gcal.fetchCalendarList(accessToken)
132 json(res, calendars)
133 } catch (err) {
134 json(res, { error: err.message }, 500)
135 }
136 })
137
138 // Update selected calendars
139 router.put('/api/gcal/calendars', async (req, res) => {
140 const auth = await requireAuth(req, server, 'write')
141 const providerId = await getProviderIdForUser(server, auth.userId)
142 if (!providerId) return json(res, { error: 'No provider profile' }, 403)
143
144 const body = await readJson(req)
145 const { calendarIds } = body
146
147 if (!Array.isArray(calendarIds) || calendarIds.length === 0) {
148 return json(res, { error: 'calendarIds array required' }, 400)
149 }
150
151 const entry = await server.db.get(`gcal:provider:${providerId}`)
152 if (!entry || !entry.value.connected) {
153 return json(res, { error: 'Not connected' }, 400)
154 }
155
156 entry.value.calendarIds = calendarIds
157 await server.db.put(`gcal:provider:${providerId}`, entry.value)
158
159 // Clear cache (calendar selection changed)
160 const delKeys = []
161 for await (const e of server.db.createReadStream({
162 gte: `gcal:cache:${providerId}:`,
163 lte: `gcal:cache:${providerId}:~`
164 })) {
165 delKeys.push(e.key)
166 }
167 for (const key of delKeys) await server.db.del(key)
168
169 json(res, { ok: true, calendarIds })
170 })
171
172 // Disconnect Google Calendar
173 router.delete('/api/gcal/disconnect', async (req, res) => {
174 const auth = await requireAuth(req, server, 'write')
175 const providerId = await getProviderIdForUser(server, auth.userId)
176 if (!providerId) return json(res, { error: 'No provider profile' }, 403)
177
178 await gcal.disconnect(server.db, providerId)
179 json(res, { ok: true })
180 })
181}
182
183async function getProviderIdForUser (server, userId) {
184 for await (const entry of server.db.createReadStream({
185 gte: 'provider:',
186 lte: 'provider:~'
187 })) {
188 if (entry.value.userId === userId) return entry.value.id
189 }
190 return null
191}
lib/server.js +10 -0
……@@ -26,6 +26,8 @@ import { registerBookingRoutes } from './routes/bookings.js'
2626import { registerAuthRoutes } from './routes/auth.js'
2727import { registerWebRoutes, registerPublicRoutes } from './routes/web.js'
2828import { registerDashboardRoutes } from './routes/dashboard.js'
29import { registerGcalRoutes } from './routes/gcal.js'
30import { GoogleCalendarClient } from './gcal.js'
2931
3032export class HyperBookingServer {
3133 constructor (options = {}) {
……@@ -53,6 +55,13 @@ export class HyperBookingServer {
5355 trustedProxies: options.trustedProxies
5456 })
5557
58 // Google Calendar integration (optional — requires GCAL_CLIENT_ID env var)
59 const gcalClientId = options.gcalClientId || (typeof process !== 'undefined' && process.env && process.env.GCAL_CLIENT_ID)
60 const gcalClientSecret = options.gcalClientSecret || (typeof process !== 'undefined' && process.env && process.env.GCAL_CLIENT_SECRET)
61 this.gcal = gcalClientId
62 ? new GoogleCalendarClient({ clientId: gcalClientId, clientSecret: gcalClientSecret })
63 : null
64
5665 // Router
5766 this.router = new Router(this)
5867
……@@ -124,6 +133,7 @@ export class HyperBookingServer {
124133 registerAvailabilityRoutes(this.router, this)
125134 registerBookingRoutes(this.router, this)
126135 registerDashboardRoutes(this.router, this)
136 if (this.gcal) registerGcalRoutes(this.router, this)
127137 // Public slug routes MUST be last (catch-all /:slug)
128138 registerPublicRoutes(this.router, this)
129139 }
web/js/dashboard.js +86 -0
……@@ -599,6 +599,11 @@ async function renderSettings () {
599599 <p>Status: ${p.active ? '<span style="color:var(--success)">Published</span>' : '<span style="color:var(--error)">Unpublished</span>'}</p>
600600 </div>
601601
602 <div class="settings-section">
603 <h3>Google Calendar</h3>
604 <div id="gcal-section"><p class="empty">Loading...</p></div>
605 </div>
606
602607 <div class="settings-section">
603608 <h3>Cancellation Policy</h3>
604609 <form id="policy-form">
……@@ -658,11 +663,92 @@ async function renderSettings () {
658663 showToast('Policy saved', 'success')
659664 } catch (err) { showToast(err.message, 'error') }
660665 })
666
667 // Google Calendar section
668 loadGcalSection()
661669 } catch (err) {
662670 content.innerHTML = `<p class="empty">Error: ${err.message}</p>`
663671 }
664672}
665673
674async function loadGcalSection () {
675 const gcalEl = document.getElementById('gcal-section')
676 if (!gcalEl) return
677
678 try {
679 const status = await api('GET', '/api/gcal/status')
680
681 if (status.connected) {
682 let html = '<p style="color:var(--success);margin-bottom:0.5rem;">Connected</p>'
683 html += '<p style="font-size:0.9rem;color:var(--text-secondary);margin-bottom:1rem;">Monitoring: ' + (status.calendarIds || ['primary']).join(', ') + '</p>'
684 html += '<div style="display:flex;gap:0.5rem;flex-wrap:wrap;">'
685 html += '<button class="btn btn-secondary" id="gcal-pick-btn">Change Calendars</button>'
686 html += '<button class="btn btn-danger" id="gcal-disconnect-btn">Disconnect</button>'
687 html += '</div>'
688 html += '<div id="gcal-picker" style="display:none;margin-top:1rem;"></div>'
689
690 gcalEl.innerHTML = html
691
692 document.getElementById('gcal-disconnect-btn').addEventListener('click', async () => {
693 if (!confirm('Disconnect Google Calendar? Booking slots will no longer reflect your calendar events.')) return
694 try {
695 await api('DELETE', '/api/gcal/disconnect')
696 showToast('Google Calendar disconnected', 'success')
697 loadGcalSection()
698 } catch (err) { showToast(err.message, 'error') }
699 })
700
701 document.getElementById('gcal-pick-btn').addEventListener('click', async () => {
702 const picker = document.getElementById('gcal-picker')
703 if (picker.style.display !== 'none') { picker.style.display = 'none'; return }
704
705 try {
706 const calendars = await api('GET', '/api/gcal/calendars')
707 const currentIds = status.calendarIds || ['primary']
708
709 let pickerHtml = '<p style="font-size:0.9rem;margin-bottom:0.5rem;">Select calendars to check for conflicts:</p>'
710 for (const cal of calendars) {
711 const checked = currentIds.includes(cal.id) ? 'checked' : ''
712 pickerHtml += `<label style="display:block;margin:0.3rem 0;font-size:0.9rem;">
713 <input type="checkbox" class="gcal-cal-checkbox" value="${esc(cal.id)}" ${checked}>
714 ${esc(cal.summary)}${cal.primary ? ' (primary)' : ''}
715 </label>`
716 }
717 pickerHtml += '<button class="btn btn-accent" id="gcal-save-cals" style="margin-top:0.5rem;">Save</button>'
718 picker.innerHTML = pickerHtml
719 picker.style.display = ''
720
721 document.getElementById('gcal-save-cals').addEventListener('click', async () => {
722 const checked = Array.from(document.querySelectorAll('.gcal-cal-checkbox:checked')).map(cb => cb.value)
723 if (checked.length === 0) { showToast('Select at least one calendar', 'error'); return }
724 try {
725 await api('PUT', '/api/gcal/calendars', { calendarIds: checked })
726 showToast('Calendars updated', 'success')
727 loadGcalSection()
728 } catch (err) { showToast(err.message, 'error') }
729 })
730 } catch (err) { showToast(err.message, 'error') }
731 })
732 } else {
733 gcalEl.innerHTML = `
734 <p style="color:var(--text-secondary);margin-bottom:1rem;">
735 Connect your Google Calendar to automatically block times when you have events.
736 We only read availability (busy/free) — never event details.
737 </p>
738 <button class="btn btn-accent" id="gcal-connect-btn">Connect Google Calendar</button>
739 `
740 document.getElementById('gcal-connect-btn').addEventListener('click', async () => {
741 try {
742 const { url } = await api('GET', '/api/gcal/auth-url')
743 window.location.href = url
744 } catch (err) { showToast(err.message, 'error') }
745 })
746 }
747 } catch {
748 gcalEl.innerHTML = '<p style="color:var(--text-secondary);">Calendar integration not configured. Set GCAL_CLIENT_ID to enable.</p>'
749 }
750}
751
666752// ---- Profile ----
667753
668754async function renderProfile () {