import Joi from 'joi'; import Boom from '@hapi/boom'; import { credentials, authResponse } from '../plugins/auth.js'; import { env } from '../config/env.js'; import { User } from '../models/User.js'; import { applyGoogleTokens, createCalendarEvent, disconnectGoogle, exchangeCode, getConnectUrl, googleConfigured, signConnectState, verifyConnectState, } from '../services/google.js'; function pad(n) { return String(n).padStart(2, '0'); } function combineDateTime(date, time) { const normalized = time.length === 5 ? `${time}:00` : time; return `${date}T${normalized}`; } function addOneHour(date, time) { const [year, month, day] = date.split('-').map(Number); const [hours, minutes] = time.split(':').map(Number); const dt = new Date(year, month - 1, day, hours, minutes); dt.setHours(dt.getHours() + 1); return { date: `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}`, time: `${pad(dt.getHours())}:${pad(dt.getMinutes())}`, }; } function eventRange({ date, startTime, endTime }) { const start = combineDateTime(date, startTime); let endDate = date; let resolvedEnd = endTime; if (!resolvedEnd) { const next = addOneHour(date, startTime); endDate = next.date; resolvedEnd = next.time; } let end = combineDateTime(endDate, resolvedEnd); if (end <= start) { const [year, month, day] = date.split('-').map(Number); const nextDay = new Date(year, month - 1, day + 1); end = combineDateTime(`${nextDay.getFullYear()}-${pad(nextDay.getMonth() + 1)}-${pad(nextDay.getDate())}`, resolvedEnd); } return { start, end }; } function profileRedirect(status) { return `${env.APP_URL}/profile?google=${encodeURIComponent(status)}`; } export function googleRoutes(server) { server.route({ method: 'GET', path: '/api/google/connect', handler: async (request) => { if (!googleConfigured()) throw Boom.badRequest('Google Calendar is not configured'); const { user } = credentials(request); return { url: getConnectUrl(signConnectState(user._id)) }; }, }); server.route({ method: 'GET', path: '/api/google/callback', options: { auth: false, validate: { query: Joi.object({ code: Joi.string().allow(''), state: Joi.string().allow(''), error: Joi.string().allow(''), scope: Joi.string().allow(''), }).unknown(true), }, }, handler: async (request, h) => { const { code, state, error } = request.query; if (error || !code || !state) return h.redirect(profileRedirect('error')); const userId = verifyConnectState(state); if (!userId) return h.redirect(profileRedirect('error')); try { const user = await User.findById(userId); if (!user || !user.isActive) return h.redirect(profileRedirect('error')); const { tokens, profile } = await exchangeCode(code); applyGoogleTokens(user, tokens, profile); await user.save(); return h.redirect(profileRedirect('connected')); } catch { return h.redirect(profileRedirect('error')); } }, }); server.route({ method: 'POST', path: '/api/google/events', options: { validate: { payload: Joi.object({ title: Joi.string().trim().min(1).max(200).required(), date: Joi.string().pattern(/^\d{4}-\d{2}-\d{2}$/).required(), startTime: Joi.string().pattern(/^\d{2}:\d{2}$/).required(), endTime: Joi.string().pattern(/^\d{2}:\d{2}$/).allow('', null), timeZone: Joi.string().trim().min(1).max(100).allow('', null), autoAdmit: Joi.boolean().default(true), allowMicVideo: Joi.boolean().default(true), }), }, }, handler: async (request) => { const { user } = credentials(request); const fresh = await User.findById(user._id); if (!fresh || !fresh.isActive) throw Boom.unauthorized(); if (!fresh.google?.refreshToken && !fresh.google?.accessToken) { throw Boom.badRequest('Connect Google Calendar first'); } const { title, date, startTime, endTime, timeZone, autoAdmit, allowMicVideo } = request.payload; const { start, end } = eventRange({ date, startTime, endTime }); return createCalendarEvent(fresh, { title, start, end, timeZone: timeZone || 'UTC', autoAdmit, allowMicVideo, }); }, }); server.route({ method: 'POST', path: '/api/google/disconnect', handler: async (request) => { const { user } = credentials(request); const fresh = await User.findById(user._id).populate('role'); if (!fresh || !fresh.isActive) throw Boom.unauthorized(); await disconnectGoogle(fresh); await fresh.populate('role'); const { token, ...rest } = authResponse(fresh, 'unused'); return rest; }, }); }