305 lines
8.3 KiB
TypeScript
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();
|