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.
147 lines
4.5 KiB
TypeScript
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;
|
|
}
|
|
}
|