import { env } from '../config/env'; import { logger } from '../utils/logger'; // --- Types --- export interface GancioEvent { id: number; title: string; description: string; place_name: string; place_address: string; start_datetime: number; // Unix timestamp end_datetime?: number; tags: string[]; } interface GancioLoginResponse { access_token: string; token_type: string; } // --- Client --- class GancioClient { private accessToken: string | null = null; private tokenExpiresAt = 0; private get baseUrl(): string { return env.GANCIO_URL; } get enabled(): boolean { return env.GANCIO_SYNC_ENABLED === 'true' && !!env.GANCIO_ADMIN_PASSWORD; } /** * Authenticate with Gancio OAuth password grant, cache token for 1 hour. * Gancio uses POST /oauth/login with application/x-www-form-urlencoded body * (oauth2orize standard). Fields: username, password, client_id="self", grant_type="password". */ private async login(): Promise { if (this.accessToken && Date.now() < this.tokenExpiresAt) return; const url = `${this.baseUrl}/oauth/login`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); // Gancio's oauth2orize endpoint requires URL-encoded form data (not JSON) const formBody = new URLSearchParams({ username: env.GANCIO_ADMIN_USER, password: env.GANCIO_ADMIN_PASSWORD, client_id: 'self', grant_type: 'password', }); try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: formBody.toString(), signal: controller.signal, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Gancio login failed (${res.status}): ${text}`); } const data = await res.json() as GancioLoginResponse; this.accessToken = data.access_token; // Cache for 1 hour this.tokenExpiresAt = Date.now() + 60 * 60 * 1000; logger.debug('Gancio auth token refreshed'); } finally { clearTimeout(timeout); } } /** * Make an authenticated JSON request to the Gancio API */ private async request( method: string, path: string, body?: Record, ): Promise { await this.login(); const url = `${this.baseUrl}${path}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10000); const headers: Record = {}; if (this.accessToken) { headers['Authorization'] = `Bearer ${this.accessToken}`; } let fetchBody: string | undefined; if (body) { headers['Content-Type'] = 'application/json'; fetchBody = JSON.stringify(body); } try { const res = await fetch(url, { method, headers, body: fetchBody, signal: controller.signal, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`Gancio API ${method} ${path} returned ${res.status}: ${text}`); } const contentType = res.headers.get('content-type') || ''; if (contentType.includes('application/json')) { return await res.json() as T; } return {} as T; } finally { clearTimeout(timeout); } } // --- Health --- async isAvailable(): Promise { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { const res = await fetch(`${this.baseUrl}/api/events`, { signal: controller.signal, }); return res.ok; } finally { clearTimeout(timeout); } } catch { return false; } } // --- Event CRUD --- /** * Create a Gancio event from a shift */ async createEvent(event: { title: string; description?: string | null; location?: string | null; date: Date; startTime: string; endTime: string; tags?: string[]; }): Promise { if (!this.enabled) return null; try { const startDatetime = this.buildTimestamp(event.date, event.startTime); const endDatetime = this.buildTimestamp(event.date, event.endTime); const placeName = event.location || 'TBD'; const created = await this.request('POST', '/api/event', { title: event.title, description: event.description || '', place_name: placeName, place_address: event.location || placeName, start_datetime: startDatetime, end_datetime: endDatetime, tags: event.tags ?? ['volunteer', 'shift'], }); logger.info(`Gancio: created event ${created.id} for "${event.title}"`); return created.id; } catch (err) { logger.warn('Gancio createEvent failed:', err instanceof Error ? err.message : err); return null; } } /** * Update an existing Gancio event */ async updateEvent(eventId: number, shift: { title: string; description?: string | null; location?: string | null; date: Date; startTime: string; endTime: string; }): Promise { if (!this.enabled) return; try { const startDatetime = this.buildTimestamp(shift.date, shift.startTime); const endDatetime = this.buildTimestamp(shift.date, shift.endTime); const placeName = shift.location || 'TBD'; await this.request('PUT', '/api/event', { id: eventId, title: shift.title, description: shift.description || '', place_name: placeName, place_address: shift.location || placeName, start_datetime: startDatetime, end_datetime: endDatetime, tags: ['volunteer', 'shift'], }); logger.info(`Gancio: updated event ${eventId}`); } catch (err) { logger.warn(`Gancio updateEvent(${eventId}) failed:`, err instanceof Error ? err.message : err); } } /** * Delete a Gancio event */ async deleteEvent(eventId: number): Promise { if (!this.enabled) return; try { await this.request('DELETE', `/api/event/${eventId}`); logger.info(`Gancio: deleted event ${eventId}`); } catch (err) { logger.warn(`Gancio deleteEvent(${eventId}) failed:`, err instanceof Error ? err.message : err); } } // --- Public Fetch (no auth needed) --- /** * Fetch events from Gancio's public API (no auth required). * Returns raw Gancio events array, or empty array on failure. */ async fetchPublicEvents(): Promise { try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); try { const res = await fetch(`${this.baseUrl}/api/events`, { signal: controller.signal, }); if (!res.ok) return []; return await res.json() as GancioEvent[]; } finally { clearTimeout(timeout); } } catch { return []; } } // --- Settings --- /** * Get all Gancio instance settings (flat JSON object). * Returns null on failure (fire-and-forget safe). */ async getSettings(): Promise | null> { if (!this.enabled) return null; try { return await this.request>('GET', '/api/settings'); } catch (err) { logger.warn('Gancio getSettings failed:', err instanceof Error ? err.message : err); return null; } } /** * Set a single Gancio setting. Gancio's API only accepts one key/value per call. * Returns true on success, false on failure. */ async setSetting(key: string, value: unknown): Promise { if (!this.enabled) return false; try { await this.request('POST', '/api/settings', { key, value }); return true; } catch (err) { logger.warn(`Gancio setSetting("${key}") failed:`, err instanceof Error ? err.message : err); return false; } } // --- Helpers --- /** * Combine a Date and "HH:MM" time string into a Unix timestamp (seconds) */ private buildTimestamp(date: Date, time: string): number { const d = new Date(date); const [h, m] = time.split(':').map(Number); d.setHours(h || 0, m || 0, 0, 0); return Math.floor(d.getTime() / 1000); } } export const gancioClient = new GancioClient();