import { and, eq, gt } from "drizzle-orm"; import { db } from "@/lib/db"; import { gardenSpeciesCache } from "../schema"; export type SpeciesSuggestion = { id: string; common_name: string; scientific_name: string; watering: string; sunlight: string[]; cycle: string; default_image_url: string | null; }; const CACHE_TTL_MS = 24 * 60 * 60 * 1000; const BASE_URL = "https://open.plantbook.io/api/v1"; type TokenCache = { access_token: string; expiresAt: number } | null; let tokenCache: TokenCache = null; async function getToken(): Promise { const clientId = process.env.OPENPLANTBOOK_CLIENT_ID; const clientSecret = process.env.OPENPLANTBOOK_CLIENT_SECRET; if (!clientId || !clientSecret) return null; if (tokenCache && Date.now() < tokenCache.expiresAt - 60_000) { return tokenCache.access_token; } try { const res = await fetch(`${BASE_URL}/token/`, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "client_credentials", client_id: clientId, client_secret: clientSecret, }), cache: "no-store", }); if (!res.ok) throw new Error(`token request failed: ${res.status}`); const json = (await res.json()) as { access_token: string; expires_in: number }; tokenCache = { access_token: json.access_token, expiresAt: Date.now() + json.expires_in * 1000, }; return tokenCache.access_token; } catch (err) { console.error("[species-lookup] getToken failed", err); return null; } } function parseSunlight(value: unknown): string[] { if (Array.isArray(value)) return value.map(String); if (typeof value === "string" && value) return value.split(", "); return []; } function parseItem(item: Record): SpeciesSuggestion { return { id: String(item.pid ?? ""), common_name: String(item.alias ?? ""), scientific_name: String(item.display_pid ?? ""), watering: String(item.watering ?? ""), sunlight: parseSunlight(item.sunlight), cycle: "", default_image_url: item.image_url ? String(item.image_url) : null, }; } export async function searchSpecies(query: string): Promise { if (!query.trim()) return []; const token = await getToken(); if (!token) return []; try { const url = `${BASE_URL}/plant/search?alias=${encodeURIComponent(query)}&limit=20`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, cache: "no-store", }); if (!res.ok) throw new Error(`OpenPlantBook search error: ${res.status} ${res.url}`); const json = (await res.json()) as { results?: unknown[] }; if (!Array.isArray(json.results)) return []; const pids = json.results .map((r) => String((r as Record).pid ?? "")) .filter(Boolean); const details = await Promise.allSettled(pids.map((pid) => getSpeciesById(pid))); return details .filter( (r): r is PromiseFulfilledResult => r.status === "fulfilled" && r.value !== null, ) .map((r) => r.value); } catch (err) { console.error("[species-lookup] searchSpecies failed", err); return []; } } export async function getSpeciesById(id: string): Promise { const cutoff = new Date(Date.now() - CACHE_TTL_MS); const [cached] = await db .select() .from(gardenSpeciesCache) .where(and(eq(gardenSpeciesCache.speciesId, id), gt(gardenSpeciesCache.cachedAt, cutoff))) .limit(1); if (cached) return cached.data as unknown as SpeciesSuggestion; const token = await getToken(); if (!token) return null; try { const url = `${BASE_URL}/plant/detail/${encodeURIComponent(id)}?include=care`; const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` }, cache: "no-store", }); if (!res.ok) return null; const item = (await res.json()) as Record; const result = parseItem(item); await db .insert(gardenSpeciesCache) .values({ speciesId: result.id, data: result as unknown as Record, cachedAt: new Date(), }) .onConflictDoUpdate({ target: gardenSpeciesCache.speciesId, set: { data: result as unknown as Record, cachedAt: new Date() }, }); return result; } catch (err) { console.error("[species-lookup] getSpeciesById failed", err); return null; } }