import { and, desc, eq, lte, sql } from "drizzle-orm"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema"; export type ContainerDto = { id: string; householdId: string; name: string; type: string; locationNotes: string | null; coverImageUrl: string | null; images: string[]; plantCount: number; createdAt: string; updatedAt: string; }; export type ContainerDetailDto = ContainerDto & { plants: PlantSummaryDto[]; }; export type PlantSummaryDto = { id: string; name: string; scientificName: string | null; healthStatus: string; primaryImageUrl: string | null; category: string; }; export async function listContainers(): Promise { const { household } = await getCurrentSession(); const rows = await db .select({ id: gardenContainers.id, householdId: gardenContainers.householdId, name: gardenContainers.name, type: gardenContainers.type, locationNotes: gardenContainers.locationNotes, coverImageUrl: gardenContainers.coverImageUrl, images: gardenContainers.images, createdAt: gardenContainers.createdAt, updatedAt: gardenContainers.updatedAt, plantCount: sql`count(${gardenPlants.id})::int`, }) .from(gardenContainers) .leftJoin(gardenPlants, eq(gardenPlants.containerId, gardenContainers.id)) .where(eq(gardenContainers.householdId, household.id)) .groupBy(gardenContainers.id) .orderBy(gardenContainers.name); return rows.map((r) => ({ ...r, images: r.images ?? [], plantCount: r.plantCount ?? 0, createdAt: r.createdAt.toISOString(), updatedAt: r.updatedAt.toISOString(), })); } export async function getContainer(id: string): Promise { const { household } = await getCurrentSession(); const [container] = await db .select({ id: gardenContainers.id, householdId: gardenContainers.householdId, name: gardenContainers.name, type: gardenContainers.type, locationNotes: gardenContainers.locationNotes, coverImageUrl: gardenContainers.coverImageUrl, images: gardenContainers.images, createdAt: gardenContainers.createdAt, updatedAt: gardenContainers.updatedAt, plantCount: sql`(select count(*)::int from garden_plants where container_id = ${gardenContainers.id})`, }) .from(gardenContainers) .where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, household.id))) .limit(1); if (!container) return null; const plants = await db .select({ id: gardenPlants.id, name: gardenPlants.name, scientificName: gardenPlants.scientificName, healthStatus: gardenPlants.healthStatus, primaryImageUrl: gardenPlants.primaryImageUrl, category: gardenPlants.category, }) .from(gardenPlants) .where(eq(gardenPlants.containerId, id)) .orderBy(desc(gardenPlants.name)); return { id: container.id, householdId: container.householdId, name: container.name, type: container.type, locationNotes: container.locationNotes, coverImageUrl: container.coverImageUrl, images: container.images ?? [], plantCount: container.plantCount ?? 0, createdAt: container.createdAt.toISOString(), updatedAt: container.updatedAt.toISOString(), plants, }; } export async function canAccessContainer(id: string, householdId: string): Promise { const [row] = await db .select({ id: gardenContainers.id }) .from(gardenContainers) .where(and(eq(gardenContainers.id, id), eq(gardenContainers.householdId, householdId))) .limit(1); return !!row; } export async function searchContainers(query: string, householdId: string) { const rows = await db .select({ id: gardenContainers.id, name: gardenContainers.name }) .from(gardenContainers) .where( and( eq(gardenContainers.householdId, householdId), sql`${gardenContainers.name} ilike ${`%${query}%`}`, ), ) .limit(10); return rows.map((r) => ({ id: r.id, title: r.name, 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`( 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`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 { 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}`, })); } // ─── Care queries ───────────────────────────────────────────────────────────── export type CareLogDto = { id: string; careType: string; notes: string | null; performedAt: string; performedBy: string | null; }; export type CareScheduleDto = { id: string; careType: string; intervalDays: number; lastPerformedAt: string | null; nextDueAt: string | null; enabled: boolean; daysUntilDue: number | null; isOverdue: boolean; }; export async function getCareLogs(plantId: string, limit = 20): Promise { const { household } = await getCurrentSession(); const rows = await db .select({ id: gardenCareLogs.id, careType: gardenCareLogs.careType, notes: gardenCareLogs.notes, performedAt: gardenCareLogs.performedAt, performedBy: gardenCareLogs.performedBy, }) .from(gardenCareLogs) .where(and(eq(gardenCareLogs.plantId, plantId), eq(gardenCareLogs.householdId, household.id))) .orderBy(desc(gardenCareLogs.performedAt)) .limit(limit); return rows.map((r) => ({ id: r.id, careType: r.careType, notes: r.notes, performedAt: r.performedAt.toISOString(), performedBy: r.performedBy, })); } export async function getCareSchedules(plantId: string): Promise { const { household } = await getCurrentSession(); const rows = await db .select() .from(gardenCareSchedules) .where( and( eq(gardenCareSchedules.plantId, plantId), eq(gardenCareSchedules.householdId, household.id), ), ) .orderBy(gardenCareSchedules.careType); const now = new Date(); return rows.map((s) => { const next = s.nextDueAt; const daysUntilDue = next ? Math.ceil((next.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)) : null; return { id: s.id, careType: s.careType, intervalDays: s.intervalDays, lastPerformedAt: s.lastPerformedAt?.toISOString() ?? null, nextDueAt: next?.toISOString() ?? null, enabled: s.enabled, daysUntilDue, isOverdue: daysUntilDue !== null && daysUntilDue < 0, }; }); } export type OverduePlantDto = { id: string; name: string; primaryImageUrl: string | null; mostOverdueAt: Date; }; export async function getOverduePlants(householdId: string): Promise { const rows = await db .select({ id: gardenPlants.id, name: gardenPlants.name, primaryImageUrl: gardenPlants.primaryImageUrl, mostOverdueAt: sql`min(${gardenCareSchedules.nextDueAt})`, }) .from(gardenPlants) .innerJoin( gardenCareSchedules, and( eq(gardenCareSchedules.plantId, gardenPlants.id), eq(gardenCareSchedules.enabled, true), lte(gardenCareSchedules.nextDueAt, sql`now()`), ), ) .where(eq(gardenPlants.householdId, householdId)) .groupBy(gardenPlants.id, gardenPlants.name, gardenPlants.primaryImageUrl) .orderBy(sql`min(${gardenCareSchedules.nextDueAt})`); return rows; } export type CareDueSoonDto = { id: string; name: string; primaryImageUrl: string | null; nextDueAt: Date; }; export async function getCareDueSoon( householdId: string, withinDays: number, ): Promise { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() + withinDays); const rows = await db .select({ id: gardenPlants.id, name: gardenPlants.name, primaryImageUrl: gardenPlants.primaryImageUrl, nextDueAt: sql`min(${gardenCareSchedules.nextDueAt})`, }) .from(gardenPlants) .innerJoin( gardenCareSchedules, and( eq(gardenCareSchedules.plantId, gardenPlants.id), eq(gardenCareSchedules.enabled, true), lte(gardenCareSchedules.nextDueAt, cutoff), ), ) .where(eq(gardenPlants.householdId, householdId)) .groupBy(gardenPlants.id, gardenPlants.name, gardenPlants.primaryImageUrl) .orderBy(sql`min(${gardenCareSchedules.nextDueAt})`); return rows; } // ─── Widget queries ─────────────────────────────────────────────────────────── export type CareDueWidgetRow = { plantId: string; plantName: string; scheduleId: string; careType: string; nextDueAt: Date | null; daysUntilDue: number; }; export async function getCareDueWidgetRows( householdId: string, containerIds: "all" | string[], daysAhead: number, ): Promise { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() + daysAhead); const baseConditions = [ eq(gardenCareSchedules.householdId, householdId), eq(gardenCareSchedules.enabled, true), lte(gardenCareSchedules.nextDueAt, cutoff), ]; const containerFilter = containerIds !== "all" && containerIds.length > 0 ? sql`${gardenPlants.containerId} = ANY(ARRAY[${sql.join( containerIds.map((id) => sql`${id}::uuid`), sql`, `, )}])` : undefined; const where = containerFilter !== undefined ? and(...baseConditions, containerFilter) : and(...baseConditions); const rows = await db .select({ plantId: gardenPlants.id, plantName: gardenPlants.name, scheduleId: gardenCareSchedules.id, careType: gardenCareSchedules.careType, nextDueAt: gardenCareSchedules.nextDueAt, }) .from(gardenCareSchedules) .innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id)) .where(where) .orderBy(gardenCareSchedules.nextDueAt); const now = Date.now(); return rows.map((r) => ({ plantId: r.plantId, plantName: r.plantName, scheduleId: r.scheduleId, careType: r.careType, nextDueAt: r.nextDueAt, daysUntilDue: r.nextDueAt ? Math.ceil((r.nextDueAt.getTime() - now) / (1000 * 60 * 60 * 24)) : 0, })); } export type GardenOverviewStats = { plantCount: number; containerCount: number; overdueCount: number; nextCare: { plantName: string; careType: string; daysUntilDue: number } | null; }; export async function getGardenOverviewStats(householdId: string): Promise { const [plantRow, containerRow] = await Promise.all([ db .select({ count: sql`count(*)::int` }) .from(gardenPlants) .where(eq(gardenPlants.householdId, householdId)), db .select({ count: sql`count(*)::int` }) .from(gardenContainers) .where(eq(gardenContainers.householdId, householdId)), ]); const plantCount = plantRow[0]?.count ?? 0; const containerCount = containerRow[0]?.count ?? 0; const overdueRows = await db .select({ count: sql`count(*)::int` }) .from(gardenCareSchedules) .where( and( eq(gardenCareSchedules.householdId, householdId), eq(gardenCareSchedules.enabled, true), lte(gardenCareSchedules.nextDueAt, sql`now()`), ), ); const overdueCount = overdueRows[0]?.count ?? 0; const nextRows = await db .select({ plantName: gardenPlants.name, careType: gardenCareSchedules.careType, nextDueAt: gardenCareSchedules.nextDueAt, }) .from(gardenCareSchedules) .innerJoin(gardenPlants, eq(gardenCareSchedules.plantId, gardenPlants.id)) .where( and(eq(gardenCareSchedules.householdId, householdId), eq(gardenCareSchedules.enabled, true)), ) .orderBy(gardenCareSchedules.nextDueAt) .limit(1); const next = nextRows[0]; const now = Date.now(); const nextCare = next?.nextDueAt ? { plantName: next.plantName, careType: next.careType, daysUntilDue: Math.ceil((next.nextDueAt.getTime() - now) / (1000 * 60 * 60 * 24)), } : null; return { plantCount, containerCount, overdueCount, nextCare }; }