feat: garden plants crud (task 72)
- perenual species lookup with 24h db cache (api key optional) - /api/garden/species-search route handler - createPlant / updatePlant / deletePlant server actions - addPlantImage / removePlantImage / setPrimaryImage actions (max 10) - listPlants query grouped by container with last-watered subquery - getPlant query with care log summary and active schedules - plant-form client component with species autofill and image upload - plant-list server component grouped by container with unassigned bucket - plant-detail client component with info/gallery/care tabs - species-search debounced combobox component - /garden/plants/new, /[id], /[id]/edit pages - garden.plant entity registered with search and share support - /garden page updated to containers/plants tabs (default: containers)
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
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://perenual.com";
|
||||
|
||||
function parseItem(item: Record<string, unknown>): SpeciesSuggestion {
|
||||
const rawNames = item.scientific_name;
|
||||
const scientificName = Array.isArray(rawNames) && rawNames.length > 0 ? String(rawNames[0]) : "";
|
||||
|
||||
const sunlight = Array.isArray(item.sunlight) ? item.sunlight.map(String) : [];
|
||||
|
||||
const defaultImage = item.default_image as Record<string, unknown> | null | undefined;
|
||||
const rawImageUrl = defaultImage?.regular_url ?? defaultImage?.medium_url ?? null;
|
||||
|
||||
return {
|
||||
id: String(item.id),
|
||||
common_name: String(item.common_name ?? ""),
|
||||
scientific_name: scientificName,
|
||||
watering: String(item.watering ?? ""),
|
||||
sunlight,
|
||||
cycle: String(item.cycle ?? ""),
|
||||
default_image_url: rawImageUrl ? String(rawImageUrl) : null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchSpecies(query: string): Promise<SpeciesSuggestion[]> {
|
||||
const apiKey = process.env.PERENUAL_API_KEY;
|
||||
if (!apiKey) return [];
|
||||
if (!query.trim()) return [];
|
||||
|
||||
try {
|
||||
const url = `${BASE_URL}/api/species-list?key=${encodeURIComponent(apiKey)}&q=${encodeURIComponent(query)}`;
|
||||
const res = await fetch(url, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(`Perenual API error: ${res.status}`);
|
||||
|
||||
const json = (await res.json()) as { data?: unknown[] };
|
||||
if (!Array.isArray(json.data)) return [];
|
||||
|
||||
const results = json.data.map((item) => parseItem(item as Record<string, unknown>));
|
||||
|
||||
await Promise.allSettled(
|
||||
results.map((r) =>
|
||||
db
|
||||
.insert(gardenSpeciesCache)
|
||||
.values({
|
||||
speciesId: r.id,
|
||||
data: r as unknown as Record<string, unknown>,
|
||||
cachedAt: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: gardenSpeciesCache.speciesId,
|
||||
set: { data: r as unknown as Record<string, unknown>, cachedAt: new Date() },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
return results;
|
||||
} 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 apiKey = process.env.PERENUAL_API_KEY;
|
||||
if (!apiKey) return null;
|
||||
|
||||
try {
|
||||
const url = `${BASE_URL}/api/species/details/${encodeURIComponent(id)}?key=${encodeURIComponent(apiKey)}`;
|
||||
const res = await fetch(url, { 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user