/opt/canhelp/apps/api/src/lib
NameSizeModeActions
activity.ts13240644editdlrm
apple-client-secret.ts40730644editdlrm
balance.ts35060644editdlrm
daily-limits.ts42820644editdlrm
email.ts527080644editdlrm
fomo.ts113660644editdlrm
google-oauth.ts105260644editdlrm
helper-capability.ts42070644editdlrm
messaging-rules.ts31230644editdlrm
nameUtils.ts7090644editdlrm
notif.ts189290644editdlrm
plan-history.ts10200644editdlrm
push.ts63860644editdlrm
referral.ts61100644editdlrm
telegram.ts62070644editdlrm
turnstile.ts7410644editdlrm
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) }