changemaker.lite/api/src/services/gancio.client.ts

305 lines
8.3 KiB
TypeScript

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<void> {
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<T>(
method: string,
path: string,
body?: Record<string, unknown>,
): Promise<T> {
await this.login();
const url = `${this.baseUrl}${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10000);
const headers: Record<string, string> = {};
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<boolean> {
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<number | null> {
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<GancioEvent>('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<void> {
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<void> {
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<GancioEvent[]> {
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<Record<string, unknown> | null> {
if (!this.enabled) return null;
try {
return await this.request<Record<string, unknown>>('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<boolean> {
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();