"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) { 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>, ) { 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) { 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>) { 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"); }