/opt/canhelp/apps/api/src/lib
Edit: /opt/canhelp/apps/api/src/lib/google-oauth.ts (10526B)
/**
* Google OAuth service — Calendar access + Sign-In support.
*
* Covers two use-cases with one token store (google_calendar_tokens):
* 1. Calendar sync: any user (email or Google-authenticated) connects
* their Google Calendar to enable two-way availability sync.
* 2. Google Sign-In: handled by Better Auth's socialProviders (accounts table).
*
* TODO: reuse for Google Sign-In — when Better Auth's Google provider is
* activated, match via googleUserId (sub claim) to skip duplicate
* OAuth consent for users who already connected Calendar.
*/
import { google } from 'googleapis'
import { db } from '../db.js'
import { googleCalendarTokens, specialistAvailability } from '@canhelp/db'
import { eq, and } from 'drizzle-orm'
// ─── OAuth2 client factory ────────────────────────────────────────────────
export function getOAuthClient() {
// TODO: reuse for Google Sign-In — same client credentials
return new google.auth.OAuth2(
process.env.GOOGLE_CLIENT_ID!,
process.env.GOOGLE_CLIENT_SECRET!,
process.env.GOOGLE_CALENDAR_REDIRECT_URI!,
)
}
/** Build a Google consent URL for calendar access. `state` = CanHelp userId. */
export function getCalendarAuthUrl(state: string): string {
const client = getOAuthClient()
return client.generateAuthUrl({
access_type: 'offline',
// Always show consent screen so refresh_token is always returned
prompt: 'consent',
scope: [
'openid',
'email',
'profile',
'https://www.googleapis.com/auth/calendar.events',
],
state,
})
}
// ─── Token management ─────────────────────────────────────────────────────
/** Exchange auth code for tokens and decode id_token to get Google user info. */
export async function exchangeCode(code: string) {
const client = getOAuthClient()
const { tokens } = await client.getToken(code)
let googleUserId: string | null = null
let email: string | null = null
if (tokens.id_token) {
try {
const ticket = await client.verifyIdToken({
idToken: tokens.id_token,
audience: process.env.GOOGLE_CLIENT_ID!,
})
const payload = ticket.getPayload()
googleUserId = payload?.sub ?? null
email = payload?.email ?? null
} catch {
// Non-critical — we still have the access/refresh tokens
}
}
return { tokens, googleUserId, email }
}
/** Persist or update calendar tokens for a CanHelp user. */
export async function storeCalendarToken(
userId: string,
tokens: { access_token?: string | null; refresh_token?: string | null; expiry_date?: number | null; scope?: string | null },
googleUserId: string | null,
email: string | null,
) {
const data = {
accessToken: tokens.access_token!,
refreshToken: tokens.refresh_token ?? null,
expiresAt: tokens.expiry_date ? new Date(tokens.expiry_date) : null,
calendarEmail: email,
googleUserId,
scopes: tokens.scope ?? null,
updatedAt: new Date(),
}
const [existing] = await db
.select({ id: googleCalendarTokens.id })
.from(googleCalendarTokens)
.where(eq(googleCalendarTokens.userId, userId))
.limit(1)
if (existing) {
await db
.update(googleCalendarTokens)
.set(data)
.where(eq(googleCalendarTokens.userId, userId))
} else {
await db.insert(googleCalendarTokens).values({ userId, ...data })
}
}
/**
* Get an authenticated OAuth2 client for a user.
* Auto-refreshes the access token if it expires within 5 minutes.
* Returns null if no token exists or if Google revoked access.
*/
async function getAuthedClient(userId: string) {
const [token] = await db
.select()
.from(googleCalendarTokens)
.where(eq(googleCalendarTokens.userId, userId))
.limit(1)
if (!token) return null
const client = getOAuthClient()
client.setCredentials({
access_token: token.accessToken,
refresh_token: token.refreshToken ?? undefined,
expiry_date: token.expiresAt ? token.expiresAt.getTime() : undefined,
})
// Refresh proactively if token expires within 5 minutes
if (!token.expiresAt || token.expiresAt.getTime() - Date.now() < 5 * 60 * 1000) {
try {
const { credentials } = await client.refreshAccessToken()
await db
.update(googleCalendarTokens)
.set({
accessToken: credentials.access_token!,
expiresAt: credentials.expiry_date ? new Date(credentials.expiry_date) : null,
updatedAt: new Date(),
})
.where(eq(googleCalendarTokens.userId, userId))
client.setCredentials(credentials)
} catch {
// Google revoked the token — clean up and return null
await db
.delete(googleCalendarTokens)
.where(eq(googleCalendarTokens.userId, userId))
return null
}
}
return client
}
// ─── Status ───────────────────────────────────────────────────────────────
export async function getCalendarStatus(userId: string) {
const [token] = await db
.select({
calendarEmail: googleCalendarTokens.calendarEmail,
googleUserId: googleCalendarTokens.googleUserId,
})
.from(googleCalendarTokens)
.where(eq(googleCalendarTokens.userId, userId))
.limit(1)
if (!token) return { connected: false }
return { connected: true, email: token.calendarEmail, googleUserId: token.googleUserId }
}
export async function disconnectCalendar(userId: string) {
const [token] = await db
.select({ accessToken: googleCalendarTokens.accessToken })
.from(googleCalendarTokens)
.where(eq(googleCalendarTokens.userId, userId))
.limit(1)
if (token) {
const client = getOAuthClient()
// Best-effort revoke — OK to fail if token already expired
await client.revokeToken(token.accessToken).catch(() => {})
await db.delete(googleCalendarTokens).where(eq(googleCalendarTokens.userId, userId))
}
}
// ─── Calendar sync helpers ────────────────────────────────────────────────
/**
* Deterministic Google Calendar event ID for a CanHelp availability record.
* Format: canhellavailYYYYMMDD — 22 chars, all lowercase letters a-v and digits,
* which satisfies Google Calendar's base32hex ID constraints.
*/
function availEventId(date: string): string {
return `canhellavail${date.replace(/-/g, '')}`
}
/**
* Push a single availability change to Google Calendar.
* Fire-and-forget: called after DB write, errors are logged but not thrown.
*/
export async function pushAvailabilityChange(
userId: string,
date: string,
status: 'available' | 'busy' | null,
): Promise
{
const client = await getAuthedClient(userId)
if (!client) return
const calendarApi = google.calendar({ version: 'v3', auth: client as any })
const eventId = availEventId(date)
if (status === null) {
await calendarApi.events.delete({ calendarId: 'primary', eventId }).catch(() => {})
return
}
// 2 = sage (green), 11 = tomato (red)
const colorId = status === 'available' ? '2' : '11'
const summary = status === 'available' ? 'CanHelp: Ελεύθερος/η' : 'CanHelp: Απασχολημένος/η'
const eventBody = {
summary,
colorId,
start: { date },
end: { date },
description: `canhelp-availability:${date}`,
}
// Try update first, fallback to insert
const existing = await calendarApi.events
.get({ calendarId: 'primary', eventId })
.catch(() => null)
if (existing?.data?.id) {
await calendarApi.events
.update({ calendarId: 'primary', eventId, requestBody: eventBody })
.catch(() => {})
} else {
await calendarApi.events
.insert({ calendarId: 'primary', requestBody: { ...eventBody, id: eventId } })
.catch(() => {})
}
}
/**
* Import busy time blocks from Google Calendar into specialist_availability.
* Uses freebusy query for the given date range.
*/
export async function importBusyTimes(userId: string, from: string, to: string): Promise {
const client = await getAuthedClient(userId)
if (!client) return 0
const calendarApi = google.calendar({ version: 'v3', auth: client as any })
const res = await calendarApi.freebusy.query({
requestBody: {
timeMin: `${from}T00:00:00Z`,
timeMax: `${to}T23:59:59Z`,
items: [{ id: 'primary' }],
},
})
const busyPeriods = res.data.calendars?.primary?.busy ?? []
const today = new Date().toISOString().slice(0, 10)
let count = 0
for (const period of busyPeriods) {
if (!period.start || !period.end) continue
const startDate = period.start.slice(0, 10)
const endDate = period.end.slice(0, 10)
const cur = new Date(startDate)
const end = new Date(endDate)
while (cur <= end) {
const dateStr = cur.toISOString().slice(0, 10)
if (dateStr >= today) {
const [existing] = await db
.select({ id: specialistAvailability.id })
.from(specialistAvailability)
.where(
and(
eq(specialistAvailability.specialistId, userId),
eq(specialistAvailability.date, dateStr),
),
)
.limit(1)
if (existing) {
await db
.update(specialistAvailability)
.set({ status: 'busy', updatedAt: new Date() })
.where(
and(
eq(specialistAvailability.specialistId, userId),
eq(specialistAvailability.date, dateStr),
),
)
} else {
await db.insert(specialistAvailability).values({
specialistId: userId,
date: dateStr,
status: 'busy',
})
}
count++
}
cur.setDate(cur.getDate() + 1)
}
}
return count
}
/**
* Auto-run on first calendar connect: import busy times for today + 3 months.
*/
export async function runFirstImport(userId: string) {
const today = new Date().toISOString().slice(0, 10)
const threeMonths = new Date()
threeMonths.setMonth(threeMonths.getMonth() + 3)
const to = threeMonths.toISOString().slice(0, 10)
return importBusyTimes(userId, today, to)
}