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:
@@ -6,7 +6,7 @@ import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { logActivity } from "@/modules/_core/activity";
|
||||
import { gardenContainers } from "../schema";
|
||||
import { gardenContainers, gardenPlants } from "../schema";
|
||||
|
||||
const containerInput = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
@@ -95,3 +95,213 @@ async function assertCanAccessContainer(id: string, householdId: string) {
|
||||
|
||||
if (!row) throw new Error("Forbidden");
|
||||
}
|
||||
|
||||
// ─── Plant actions ────────────────────────────────────────────────────────────
|
||||
|
||||
const plantInput = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
category: z.string().trim().min(1).max(40).default("other"),
|
||||
containerId: z.string().uuid().nullable().optional(),
|
||||
healthStatus: z.string().trim().min(1).max(40).default("healthy"),
|
||||
growthStage: z.string().trim().max(40).nullable().optional(),
|
||||
scientificName: z.string().trim().max(200).nullable().optional(),
|
||||
speciesId: z.string().trim().max(100).nullable().optional(),
|
||||
sunlight: z.string().trim().max(200).nullable().optional(),
|
||||
wateringNotes: z.string().trim().max(2000).nullable().optional(),
|
||||
fertilizingNotes: z.string().trim().max(2000).nullable().optional(),
|
||||
notes: z.string().trim().max(2000).nullable().optional(),
|
||||
acquisitionDate: z.string().nullable().optional(),
|
||||
images: z.array(z.string().url()).max(10).default([]),
|
||||
primaryImageUrl: z.string().url().nullable().optional(),
|
||||
});
|
||||
|
||||
export async function createPlant(input: z.input<typeof plantInput>) {
|
||||
const parsed = plantInput.parse(input);
|
||||
const { household } = await getCurrentSession();
|
||||
|
||||
if (parsed.containerId) {
|
||||
await assertCanAccessContainer(parsed.containerId, household.id);
|
||||
}
|
||||
|
||||
const [plant] = await db
|
||||
.insert(gardenPlants)
|
||||
.values({
|
||||
householdId: household.id,
|
||||
name: parsed.name,
|
||||
category: parsed.category ?? "other",
|
||||
containerId: parsed.containerId ?? null,
|
||||
healthStatus: parsed.healthStatus ?? "healthy",
|
||||
growthStage: parsed.growthStage ?? null,
|
||||
scientificName: parsed.scientificName ?? null,
|
||||
speciesId: parsed.speciesId ?? null,
|
||||
sunlight: parsed.sunlight ?? null,
|
||||
wateringNotes: parsed.wateringNotes ?? null,
|
||||
fertilizingNotes: parsed.fertilizingNotes ?? null,
|
||||
notes: parsed.notes ?? null,
|
||||
acquisitionDate: parsed.acquisitionDate ?? null,
|
||||
images: parsed.images,
|
||||
primaryImageUrl: parsed.primaryImageUrl ?? parsed.images[0] ?? null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!plant) throw new Error("Plant was not created");
|
||||
|
||||
await logActivity({
|
||||
entityType: "garden.plant",
|
||||
entityId: plant.id,
|
||||
action: "create",
|
||||
payload: { name: plant.name },
|
||||
});
|
||||
revalidatePath("/garden");
|
||||
return plant;
|
||||
}
|
||||
|
||||
export async function updatePlant(input: { id: string } & Partial<z.input<typeof plantInput>>) {
|
||||
const parsed = z.object({ id: z.string().uuid() }).and(plantInput.partial()).parse(input);
|
||||
const { household } = await getCurrentSession();
|
||||
await assertCanAccessPlant(parsed.id, household.id);
|
||||
|
||||
if (parsed.containerId) {
|
||||
await assertCanAccessContainer(parsed.containerId, household.id);
|
||||
}
|
||||
|
||||
await db
|
||||
.update(gardenPlants)
|
||||
.set({
|
||||
name: parsed.name,
|
||||
category: parsed.category,
|
||||
containerId: parsed.containerId === undefined ? undefined : (parsed.containerId ?? null),
|
||||
healthStatus: parsed.healthStatus,
|
||||
growthStage: parsed.growthStage === undefined ? undefined : (parsed.growthStage ?? null),
|
||||
scientificName:
|
||||
parsed.scientificName === undefined ? undefined : (parsed.scientificName ?? null),
|
||||
speciesId: parsed.speciesId === undefined ? undefined : (parsed.speciesId ?? null),
|
||||
sunlight: parsed.sunlight === undefined ? undefined : (parsed.sunlight ?? null),
|
||||
wateringNotes:
|
||||
parsed.wateringNotes === undefined ? undefined : (parsed.wateringNotes ?? null),
|
||||
fertilizingNotes:
|
||||
parsed.fertilizingNotes === undefined ? undefined : (parsed.fertilizingNotes ?? null),
|
||||
notes: parsed.notes === undefined ? undefined : (parsed.notes ?? null),
|
||||
acquisitionDate:
|
||||
parsed.acquisitionDate === undefined ? undefined : (parsed.acquisitionDate ?? null),
|
||||
images: parsed.images,
|
||||
primaryImageUrl:
|
||||
parsed.primaryImageUrl === undefined ? undefined : (parsed.primaryImageUrl ?? null),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(gardenPlants.id, parsed.id));
|
||||
|
||||
await logActivity({
|
||||
entityType: "garden.plant",
|
||||
entityId: parsed.id,
|
||||
action: "update",
|
||||
payload: { name: parsed.name },
|
||||
});
|
||||
revalidatePath("/garden");
|
||||
revalidatePath(`/garden/plants/${parsed.id}`);
|
||||
}
|
||||
|
||||
export async function deletePlant(input: { id: string }) {
|
||||
const parsed = z.object({ id: z.string().uuid() }).parse(input);
|
||||
const { household } = await getCurrentSession();
|
||||
await assertCanAccessPlant(parsed.id, household.id);
|
||||
|
||||
const [plant] = await db
|
||||
.select({ name: gardenPlants.name })
|
||||
.from(gardenPlants)
|
||||
.where(eq(gardenPlants.id, parsed.id))
|
||||
.limit(1);
|
||||
|
||||
await logActivity({
|
||||
entityType: "garden.plant",
|
||||
entityId: parsed.id,
|
||||
action: "delete",
|
||||
payload: { name: plant?.name },
|
||||
});
|
||||
await db.delete(gardenPlants).where(eq(gardenPlants.id, parsed.id));
|
||||
revalidatePath("/garden");
|
||||
}
|
||||
|
||||
export async function addPlantImage(input: { id: string; url: string }) {
|
||||
const parsed = z.object({ id: z.string().uuid(), url: z.string().url() }).parse(input);
|
||||
const { household } = await getCurrentSession();
|
||||
await assertCanAccessPlant(parsed.id, household.id);
|
||||
|
||||
const [row] = await db
|
||||
.select({ images: gardenPlants.images })
|
||||
.from(gardenPlants)
|
||||
.where(eq(gardenPlants.id, parsed.id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) throw new Error("Plant not found");
|
||||
if (row.images.length >= 10) throw new Error("Maximum 10 images allowed");
|
||||
|
||||
const newImages = [...row.images, parsed.url];
|
||||
await db
|
||||
.update(gardenPlants)
|
||||
.set({
|
||||
images: newImages,
|
||||
primaryImageUrl: row.images.length === 0 ? parsed.url : undefined,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(gardenPlants.id, parsed.id));
|
||||
|
||||
revalidatePath(`/garden/plants/${parsed.id}`);
|
||||
}
|
||||
|
||||
export async function removePlantImage(input: { id: string; url: string }) {
|
||||
const parsed = z.object({ id: z.string().uuid(), url: z.string() }).parse(input);
|
||||
const { household } = await getCurrentSession();
|
||||
await assertCanAccessPlant(parsed.id, household.id);
|
||||
|
||||
const [row] = await db
|
||||
.select({ images: gardenPlants.images, primaryImageUrl: gardenPlants.primaryImageUrl })
|
||||
.from(gardenPlants)
|
||||
.where(eq(gardenPlants.id, parsed.id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) throw new Error("Plant not found");
|
||||
|
||||
const newImages = row.images.filter((u) => u !== parsed.url);
|
||||
const wasPrimary = row.primaryImageUrl === parsed.url;
|
||||
const newPrimary = wasPrimary ? (newImages[0] ?? null) : row.primaryImageUrl;
|
||||
|
||||
await db
|
||||
.update(gardenPlants)
|
||||
.set({ images: newImages, primaryImageUrl: newPrimary, updatedAt: new Date() })
|
||||
.where(eq(gardenPlants.id, parsed.id));
|
||||
|
||||
revalidatePath(`/garden/plants/${parsed.id}`);
|
||||
}
|
||||
|
||||
export async function setPrimaryImage(input: { id: string; url: string }) {
|
||||
const parsed = z.object({ id: z.string().uuid(), url: z.string() }).parse(input);
|
||||
const { household } = await getCurrentSession();
|
||||
await assertCanAccessPlant(parsed.id, household.id);
|
||||
|
||||
const [row] = await db
|
||||
.select({ images: gardenPlants.images })
|
||||
.from(gardenPlants)
|
||||
.where(eq(gardenPlants.id, parsed.id))
|
||||
.limit(1);
|
||||
|
||||
if (!row) throw new Error("Plant not found");
|
||||
if (!row.images.includes(parsed.url)) throw new Error("Image not in plant gallery");
|
||||
|
||||
await db
|
||||
.update(gardenPlants)
|
||||
.set({ primaryImageUrl: parsed.url, updatedAt: new Date() })
|
||||
.where(eq(gardenPlants.id, parsed.id));
|
||||
|
||||
revalidatePath(`/garden/plants/${parsed.id}`);
|
||||
}
|
||||
|
||||
async function assertCanAccessPlant(id: string, householdId: string) {
|
||||
const [row] = await db
|
||||
.select({ id: gardenPlants.id })
|
||||
.from(gardenPlants)
|
||||
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, householdId)))
|
||||
.limit(1);
|
||||
|
||||
if (!row) throw new Error("Forbidden");
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { getCurrentSession } from "@/lib/session";
|
||||
import { gardenContainers, gardenPlants } from "../schema";
|
||||
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
|
||||
|
||||
export type ContainerDto = {
|
||||
id: string;
|
||||
@@ -133,3 +133,215 @@ export async function searchContainers(query: string, householdId: string) {
|
||||
url: `/garden/containers/${r.id}`,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Plant queries ────────────────────────────────────────────────────────────
|
||||
|
||||
export type PlantListItemDto = {
|
||||
id: string;
|
||||
containerId: string | null;
|
||||
containerName: string | null;
|
||||
name: string;
|
||||
healthStatus: string;
|
||||
primaryImageUrl: string | null;
|
||||
category: string;
|
||||
lastWateredAt: string | null;
|
||||
hasOverdueCare: boolean;
|
||||
};
|
||||
|
||||
export type CareLogSummaryDto = {
|
||||
id: string;
|
||||
careType: string;
|
||||
notes: string | null;
|
||||
performedAt: string;
|
||||
};
|
||||
|
||||
export type CareScheduleSummaryDto = {
|
||||
id: string;
|
||||
careType: string;
|
||||
intervalDays: number;
|
||||
nextDueAt: string | null;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export type PlantDetailDto = {
|
||||
id: string;
|
||||
householdId: string;
|
||||
containerId: string | null;
|
||||
containerName: string | null;
|
||||
name: string;
|
||||
scientificName: string | null;
|
||||
speciesId: string | null;
|
||||
category: string;
|
||||
notes: string | null;
|
||||
acquisitionDate: string | null;
|
||||
growthStage: string | null;
|
||||
healthStatus: string;
|
||||
sunlight: string | null;
|
||||
wateringNotes: string | null;
|
||||
fertilizingNotes: string | null;
|
||||
primaryImageUrl: string | null;
|
||||
images: string[];
|
||||
recentCareLogs: CareLogSummaryDto[];
|
||||
activeSchedules: CareScheduleSummaryDto[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export async function listPlants({ containerId }: { containerId?: string } = {}): Promise<
|
||||
PlantListItemDto[]
|
||||
> {
|
||||
const { household } = await getCurrentSession();
|
||||
|
||||
const filter = containerId
|
||||
? and(eq(gardenPlants.householdId, household.id), eq(gardenPlants.containerId, containerId))
|
||||
: eq(gardenPlants.householdId, household.id);
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: gardenPlants.id,
|
||||
containerId: gardenPlants.containerId,
|
||||
containerName: gardenContainers.name,
|
||||
name: gardenPlants.name,
|
||||
healthStatus: gardenPlants.healthStatus,
|
||||
primaryImageUrl: gardenPlants.primaryImageUrl,
|
||||
category: gardenPlants.category,
|
||||
lastWateredAt: sql<string | null>`(
|
||||
select performed_at::text from garden_care_logs
|
||||
where plant_id = ${gardenPlants.id}
|
||||
and care_type = 'watering'
|
||||
order by performed_at desc
|
||||
limit 1
|
||||
)`,
|
||||
hasOverdueCare: sql<boolean>`exists(
|
||||
select 1 from garden_care_schedules
|
||||
where plant_id = ${gardenPlants.id}
|
||||
and enabled = true
|
||||
and next_due_at < now()
|
||||
)`,
|
||||
})
|
||||
.from(gardenPlants)
|
||||
.leftJoin(gardenContainers, eq(gardenPlants.containerId, gardenContainers.id))
|
||||
.where(filter)
|
||||
.orderBy(gardenContainers.name, gardenPlants.name);
|
||||
|
||||
return rows.map((r) => ({
|
||||
...r,
|
||||
containerName: r.containerName ?? null,
|
||||
lastWateredAt: r.lastWateredAt ?? null,
|
||||
hasOverdueCare: !!r.hasOverdueCare,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getPlant(id: string): Promise<PlantDetailDto | null> {
|
||||
const { household } = await getCurrentSession();
|
||||
|
||||
const [row] = await db
|
||||
.select({
|
||||
id: gardenPlants.id,
|
||||
householdId: gardenPlants.householdId,
|
||||
containerId: gardenPlants.containerId,
|
||||
containerName: gardenContainers.name,
|
||||
name: gardenPlants.name,
|
||||
scientificName: gardenPlants.scientificName,
|
||||
speciesId: gardenPlants.speciesId,
|
||||
category: gardenPlants.category,
|
||||
notes: gardenPlants.notes,
|
||||
acquisitionDate: gardenPlants.acquisitionDate,
|
||||
growthStage: gardenPlants.growthStage,
|
||||
healthStatus: gardenPlants.healthStatus,
|
||||
sunlight: gardenPlants.sunlight,
|
||||
wateringNotes: gardenPlants.wateringNotes,
|
||||
fertilizingNotes: gardenPlants.fertilizingNotes,
|
||||
primaryImageUrl: gardenPlants.primaryImageUrl,
|
||||
images: gardenPlants.images,
|
||||
createdAt: gardenPlants.createdAt,
|
||||
updatedAt: gardenPlants.updatedAt,
|
||||
})
|
||||
.from(gardenPlants)
|
||||
.leftJoin(gardenContainers, eq(gardenPlants.containerId, gardenContainers.id))
|
||||
.where(and(eq(gardenPlants.id, id), eq(gardenPlants.householdId, household.id)))
|
||||
.limit(1);
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const careLogs = await db
|
||||
.select({
|
||||
id: gardenCareLogs.id,
|
||||
careType: gardenCareLogs.careType,
|
||||
notes: gardenCareLogs.notes,
|
||||
performedAt: gardenCareLogs.performedAt,
|
||||
})
|
||||
.from(gardenCareLogs)
|
||||
.where(eq(gardenCareLogs.plantId, id))
|
||||
.orderBy(desc(gardenCareLogs.performedAt))
|
||||
.limit(5);
|
||||
|
||||
const schedules = await db
|
||||
.select({
|
||||
id: gardenCareSchedules.id,
|
||||
careType: gardenCareSchedules.careType,
|
||||
intervalDays: gardenCareSchedules.intervalDays,
|
||||
nextDueAt: gardenCareSchedules.nextDueAt,
|
||||
enabled: gardenCareSchedules.enabled,
|
||||
})
|
||||
.from(gardenCareSchedules)
|
||||
.where(and(eq(gardenCareSchedules.plantId, id), eq(gardenCareSchedules.enabled, true)));
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
householdId: row.householdId,
|
||||
containerId: row.containerId,
|
||||
containerName: row.containerName ?? null,
|
||||
name: row.name,
|
||||
scientificName: row.scientificName,
|
||||
speciesId: row.speciesId,
|
||||
category: row.category,
|
||||
notes: row.notes,
|
||||
acquisitionDate: row.acquisitionDate,
|
||||
growthStage: row.growthStage,
|
||||
healthStatus: row.healthStatus,
|
||||
sunlight: row.sunlight,
|
||||
wateringNotes: row.wateringNotes,
|
||||
fertilizingNotes: row.fertilizingNotes,
|
||||
primaryImageUrl: row.primaryImageUrl,
|
||||
images: row.images,
|
||||
recentCareLogs: careLogs.map((l) => ({
|
||||
id: l.id,
|
||||
careType: l.careType,
|
||||
notes: l.notes,
|
||||
performedAt: l.performedAt.toISOString(),
|
||||
})),
|
||||
activeSchedules: schedules.map((s) => ({
|
||||
id: s.id,
|
||||
careType: s.careType,
|
||||
intervalDays: s.intervalDays,
|
||||
nextDueAt: s.nextDueAt?.toISOString() ?? null,
|
||||
enabled: s.enabled,
|
||||
})),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchPlants(query: string, householdId: string) {
|
||||
const rows = await db
|
||||
.select({
|
||||
id: gardenPlants.id,
|
||||
name: gardenPlants.name,
|
||||
scientificName: gardenPlants.scientificName,
|
||||
})
|
||||
.from(gardenPlants)
|
||||
.where(
|
||||
and(
|
||||
eq(gardenPlants.householdId, householdId),
|
||||
sql`(${gardenPlants.name} || ' ' || coalesce(${gardenPlants.scientificName}, '')) ilike ${`%${query}%`}`,
|
||||
),
|
||||
)
|
||||
.limit(10);
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.name + (r.scientificName ? ` (${r.scientificName})` : ""),
|
||||
url: `/garden/plants/${r.id}`,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -11,6 +11,45 @@ export type ContainerShareData = {
|
||||
plants: { id: string; name: string; scientificName: string | null }[];
|
||||
};
|
||||
|
||||
export type PlantShareData = {
|
||||
id: string;
|
||||
name: string;
|
||||
scientificName: string | null;
|
||||
primaryImageUrl: string | null;
|
||||
images: string[];
|
||||
healthStatus: string;
|
||||
growthStage: string | null;
|
||||
category: string;
|
||||
wateringNotes: string | null;
|
||||
fertilizingNotes: string | null;
|
||||
notes: string | null;
|
||||
sunlight: string | null;
|
||||
};
|
||||
|
||||
export async function loadPlantForShare(id: string): Promise<PlantShareData | null> {
|
||||
const [plant] = await db
|
||||
.select({
|
||||
id: gardenPlants.id,
|
||||
name: gardenPlants.name,
|
||||
scientificName: gardenPlants.scientificName,
|
||||
primaryImageUrl: gardenPlants.primaryImageUrl,
|
||||
images: gardenPlants.images,
|
||||
healthStatus: gardenPlants.healthStatus,
|
||||
growthStage: gardenPlants.growthStage,
|
||||
category: gardenPlants.category,
|
||||
wateringNotes: gardenPlants.wateringNotes,
|
||||
fertilizingNotes: gardenPlants.fertilizingNotes,
|
||||
notes: gardenPlants.notes,
|
||||
sunlight: gardenPlants.sunlight,
|
||||
})
|
||||
.from(gardenPlants)
|
||||
.where(eq(gardenPlants.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (!plant) return null;
|
||||
return plant;
|
||||
}
|
||||
|
||||
export async function loadContainerForShare(id: string): Promise<ContainerShareData | null> {
|
||||
const [container] = await db
|
||||
.select()
|
||||
|
||||
@@ -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