- care reminders now fire with plant-specific body ("Time to water Pothos")
via title/body columns on the reminders table (migration 0016)
- bi-directional task sync: checking off a garden-linked task list item
creates a care log; logging care from garden marks the linked task done
(list_items.metadata stores gardenPlantId + gardenCareType linkage)
- upload error response no longer leaks debug detail; error message
corrected from "5 MB" to "100 MB"
631 lines
21 KiB
TypeScript
631 lines
21 KiB
TypeScript
"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<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");
|
|
}
|
|
|
|
// ─── 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<typeof careLogInput>) {
|
|
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<typeof careScheduleInput>) {
|
|
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<typeof scheduleOnCalendarInput>) {
|
|
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 };
|
|
}
|