Files
famapp/src/modules/garden/server/actions.ts
T
ginnoir f3e38c576c 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)
2026-06-01 20:13:20 -05:00

308 lines
11 KiB
TypeScript

"use server";
import { and, eq } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { logActivity } from "@/modules/_core/activity";
import { gardenContainers, gardenPlants } from "../schema";
const containerInput = z.object({
name: z.string().trim().min(1).max(120),
type: z.string().trim().min(1).max(40).default("other"),
locationNotes: z.string().trim().max(500).nullable().optional(),
coverImageUrl: z.string().trim().max(500).nullable().optional(),
});
export async function createContainer(input: z.input<typeof containerInput>) {
const parsed = containerInput.parse(input);
const { household } = await getCurrentSession();
const [container] = await db
.insert(gardenContainers)
.values({
householdId: household.id,
name: parsed.name,
type: parsed.type,
locationNotes: parsed.locationNotes ?? null,
coverImageUrl: parsed.coverImageUrl ?? null,
})
.returning();
if (!container) throw new Error("Container was not created");
await logActivity({
entityType: "garden.container",
entityId: container.id,
action: "create",
payload: { name: container.name },
});
revalidatePath("/garden");
return container;
}
export async function updateContainer(
input: { id: string } & Partial<z.input<typeof containerInput>>,
) {
const parsed = z.object({ id: z.string().uuid() }).and(containerInput.partial()).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessContainer(parsed.id, household.id);
await db
.update(gardenContainers)
.set({
name: parsed.name,
type: parsed.type,
locationNotes:
parsed.locationNotes === undefined ? undefined : (parsed.locationNotes ?? null),
coverImageUrl:
parsed.coverImageUrl === undefined ? undefined : (parsed.coverImageUrl ?? null),
updatedAt: new Date(),
})
.where(eq(gardenContainers.id, parsed.id));
await logActivity({
entityType: "garden.container",
entityId: parsed.id,
action: "update",
payload: { name: parsed.name },
});
revalidatePath("/garden");
revalidatePath(`/garden/containers/${parsed.id}`);
}
export async function deleteContainer(input: { id: string }) {
const parsed = z.object({ id: z.string().uuid() }).parse(input);
const { household } = await getCurrentSession();
await assertCanAccessContainer(parsed.id, household.id);
await logActivity({
entityType: "garden.container",
entityId: parsed.id,
action: "delete",
});
await db.delete(gardenContainers).where(eq(gardenContainers.id, parsed.id));
revalidatePath("/garden");
}
async function assertCanAccessContainer(id: string, householdId: string) {
const [row] = await db
.select({ id: gardenContainers.id })
.from(gardenContainers)
.where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, householdId)))
.limit(1);
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");
}