Files
famapp/src/modules/garden/server/species-lookup.ts
T
ginnoir 6eab68560e fix(garden): remove trailing slashes from openplantbook api urls and update label
Trailing slashes on /plant/search/ and /plant/detail/ caused Django to
return an HTML redirect page instead of JSON. SDK uses no trailing slashes.

Also updates the helper text to reference OpenPlantBook instead of Perenual.
2026-06-02 03:48:38 -05:00

147 lines
4.5 KiB
TypeScript

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<string | null> {
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<string, unknown>): 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<SpeciesSuggestion[]> {
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<string, unknown>).pid ?? ""))
.filter(Boolean);
const details = await Promise.allSettled(pids.map((pid) => getSpeciesById(pid)));
return details
.filter(
(r): r is PromiseFulfilledResult<SpeciesSuggestion> =>
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<SpeciesSuggestion | null> {
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<string, unknown>;
const result = parseItem(item);
await db
.insert(gardenSpeciesCache)
.values({
speciesId: result.id,
data: result as unknown as Record<string, unknown>,
cachedAt: new Date(),
})
.onConflictDoUpdate({
target: gardenSpeciesCache.speciesId,
set: { data: result as unknown as Record<string, unknown>, cachedAt: new Date() },
});
return result;
} catch (err) {
console.error("[species-lookup] getSpeciesById failed", err);
return null;
}
}