"use server"; import { and, eq, lte, sql } 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 { cancelReminder, scheduleReminder } from "@/modules/_core/reminders"; import { listItems } from "@/modules/lists/schema"; import { notifyListChanged } from "@/modules/lists/server/realtime"; import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema"; import { createCalendarEvent } from "./calendar-bridge"; import { buildCareReminderBody, buildCareTitle } from "./care-utils"; import { updateScheduleAfterCare } from "./care-schedule"; import { addGardenCareTask, getList, listLists } from "./lists-bridge"; 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"); } // ─── Care log actions ───────────────────────────────────────────────────────── const careLogInput = z.object({ plantId: z.string().uuid(), careType: z.string().trim().min(1).max(40), notes: z.string().trim().max(2000).nullable().optional(), performedAt: z.string().optional(), }); export async function logCare(input: z.input) { const parsed = careLogInput.parse(input); const { household, user } = await getCurrentSession(); await assertCanAccessPlant(parsed.plantId, household.id); const performedAt = parsed.performedAt ? new Date(parsed.performedAt) : new Date(); const [log] = await db .insert(gardenCareLogs) .values({ plantId: parsed.plantId, householdId: household.id, careType: parsed.careType, performedBy: user.id, notes: parsed.notes ?? null, performedAt, }) .returning(); if (!log) throw new Error("Care log was not created"); await updateScheduleAfterCare(parsed.plantId, parsed.careType, user.id, household.id); await logActivity({ entityType: "garden.plant", entityId: parsed.plantId, action: "update", payload: { careType: parsed.careType }, }); // Mark linked task item done if one exists for this plant + care type const linkedItems = await db .select({ id: listItems.id, listId: listItems.listId }) .from(listItems) .where( and( sql`${listItems.metadata}->>'gardenPlantId' = ${parsed.plantId}`, sql`${listItems.metadata}->>'gardenCareType' = ${parsed.careType}`, eq(listItems.done, false), ), ) .limit(1); if (linkedItems[0]) { await db .update(listItems) .set({ done: true, updatedAt: new Date() }) .where(eq(listItems.id, linkedItems[0].id)); await notifyListChanged(linkedItems[0].listId); revalidatePath(`/lists/${linkedItems[0].listId}`); } revalidatePath(`/garden/plants/${parsed.plantId}`); return log; } export async function deleteCareLog(input: { id: string }) { const parsed = z.object({ id: z.string().uuid() }).parse(input); const { household } = await getCurrentSession(); const [row] = await db .select({ id: gardenCareLogs.id, plantId: gardenCareLogs.plantId }) .from(gardenCareLogs) .where(and(eq(gardenCareLogs.id, parsed.id), eq(gardenCareLogs.householdId, household.id))) .limit(1); if (!row) throw new Error("Forbidden"); await db.delete(gardenCareLogs).where(eq(gardenCareLogs.id, parsed.id)); revalidatePath(`/garden/plants/${row.plantId}`); } // ─── Care schedule actions ──────────────────────────────────────────────────── const careScheduleInput = z.object({ plantId: z.string().uuid(), careType: z.string().trim().min(1).max(40), intervalDays: z.number().int().min(1).max(365), enabled: z.boolean().optional(), }); export async function upsertCareSchedule(input: z.input) { const parsed = careScheduleInput.parse(input); const { household, user } = await getCurrentSession(); await assertCanAccessPlant(parsed.plantId, household.id); const now = new Date(); const enabled = parsed.enabled ?? true; const [existing] = await db .select({ id: gardenCareSchedules.id, lastPerformedAt: gardenCareSchedules.lastPerformedAt }) .from(gardenCareSchedules) .where( and( eq(gardenCareSchedules.plantId, parsed.plantId), eq(gardenCareSchedules.careType, parsed.careType), ), ) .limit(1); const base = existing?.lastPerformedAt ?? now; const nextDueAt = new Date(base.getTime() + parsed.intervalDays * 24 * 60 * 60 * 1000); const [schedule] = await db .insert(gardenCareSchedules) .values({ plantId: parsed.plantId, householdId: household.id, careType: parsed.careType, intervalDays: parsed.intervalDays, nextDueAt, enabled, }) .onConflictDoUpdate({ target: [gardenCareSchedules.plantId, gardenCareSchedules.careType], set: { intervalDays: parsed.intervalDays, nextDueAt, enabled, updatedAt: now }, }) .returning(); if (!schedule) throw new Error("Schedule was not created"); const [plantRow] = await db .select({ name: gardenPlants.name }) .from(gardenPlants) .where(eq(gardenPlants.id, parsed.plantId)) .limit(1); await cancelReminder("garden.schedule", schedule.id); if (enabled) { await scheduleReminder({ householdId: household.id, entityType: "garden.schedule", entityId: schedule.id, fireAt: nextDueAt, createdBy: user.id, title: "Garden care reminder", body: plantRow ? buildCareReminderBody(parsed.careType, plantRow.name) : undefined, }); } revalidatePath(`/garden/plants/${parsed.plantId}`); return schedule; } export async function deleteCareSchedule(input: { id: string }) { const parsed = z.object({ id: z.string().uuid() }).parse(input); const { household } = await getCurrentSession(); const [row] = await db .select({ id: gardenCareSchedules.id, plantId: gardenCareSchedules.plantId }) .from(gardenCareSchedules) .where( and(eq(gardenCareSchedules.id, parsed.id), eq(gardenCareSchedules.householdId, household.id)), ) .limit(1); if (!row) throw new Error("Forbidden"); await cancelReminder("garden.schedule", parsed.id); await db.delete(gardenCareSchedules).where(eq(gardenCareSchedules.id, parsed.id)); revalidatePath(`/garden/plants/${row.plantId}`); } export async function toggleCareSchedule(input: { id: string; enabled: boolean }) { const parsed = z.object({ id: z.string().uuid(), enabled: z.boolean() }).parse(input); const { household, user } = await getCurrentSession(); const [row] = await db .select() .from(gardenCareSchedules) .where( and(eq(gardenCareSchedules.id, parsed.id), eq(gardenCareSchedules.householdId, household.id)), ) .limit(1); if (!row) throw new Error("Forbidden"); await db .update(gardenCareSchedules) .set({ enabled: parsed.enabled, updatedAt: new Date() }) .where(eq(gardenCareSchedules.id, parsed.id)); await cancelReminder("garden.schedule", parsed.id); if (parsed.enabled && row.nextDueAt) { const [togglePlantRow] = await db .select({ name: gardenPlants.name }) .from(gardenPlants) .where(eq(gardenPlants.id, row.plantId)) .limit(1); await scheduleReminder({ householdId: household.id, entityType: "garden.schedule", entityId: parsed.id, fireAt: row.nextDueAt, createdBy: user.id, title: "Garden care reminder", body: togglePlantRow ? buildCareReminderBody(row.careType, togglePlantRow.name) : undefined, }); } revalidatePath(`/garden/plants/${row.plantId}`); } // ─── Calendar integration ───────────────────────────────────────────────────── const scheduleOnCalendarInput = z.object({ scheduleId: z.string().uuid(), calendarId: z.string().uuid(), reminderMinutesBefore: z.number().int().min(0).max(1440).optional(), }); export async function scheduleOnCalendar(input: z.input) { const parsed = scheduleOnCalendarInput.parse(input); const { household } = await getCurrentSession(); const [row] = await db .select({ nextDueAt: gardenCareSchedules.nextDueAt, careType: gardenCareSchedules.careType, plantName: gardenPlants.name, }) .from(gardenCareSchedules) .innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id)) .where( and( eq(gardenCareSchedules.id, parsed.scheduleId), eq(gardenCareSchedules.householdId, household.id), ), ) .limit(1); if (!row) throw new Error("Forbidden"); if (!row.nextDueAt) throw new Error("This schedule has no upcoming due date set."); const startAt = row.nextDueAt; const endAt = new Date(startAt.getTime() + 30 * 60 * 1000); const title = buildCareTitle(row.careType, row.plantName); await createCalendarEvent({ calendarId: parsed.calendarId, title, startAt, endAt, allDay: false, notes: `Scheduled from garden. Next due: ${startAt.toLocaleDateString()}`, remindMinutesBefore: parsed.reminderMinutesBefore ?? null, }); revalidatePath("/calendar"); } // ─── Lists integration ──────────────────────────────────────────────────────── export async function pushOverdueToTaskList(): Promise<{ added: number }> { const { household } = await getCurrentSession(); const overduePairs = await db .select({ plantId: gardenPlants.id, plantName: gardenPlants.name, careType: gardenCareSchedules.careType, nextDueAt: gardenCareSchedules.nextDueAt, }) .from(gardenCareSchedules) .innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id)) .where( and( eq(gardenCareSchedules.householdId, household.id), eq(gardenCareSchedules.enabled, true), lte(gardenCareSchedules.nextDueAt, sql`now()`), ), ); if (overduePairs.length === 0) return { added: 0 }; const taskLists = await listLists({ type: "task" }); const taskList = taskLists[0]; if (!taskList) return { added: 0 }; const listDetail = await getList(taskList.id); const existingTexts = new Set(listDetail.items.map((i) => i.text)); let added = 0; for (const pair of overduePairs) { const title = buildCareTitle(pair.careType, pair.plantName); if (existingTexts.has(title)) continue; const daysOverdue = pair.nextDueAt ? Math.abs(Math.floor((Date.now() - pair.nextDueAt.getTime()) / (1000 * 60 * 60 * 24))) : 0; await addGardenCareTask({ listId: taskList.id, text: title, notes: `Overdue by ${daysOverdue} day(s)`, dueAt: pair.nextDueAt, gardenPlantId: pair.plantId, gardenCareType: pair.careType, }); existingTexts.add(title); added++; } return { added }; }