feat: garden care tracking - logs, schedules, reminders (task 73)
- logCare/deleteCareLog actions with schedule update and reminder wiring - upsertCareSchedule/deleteCareSchedule/toggleCareSchedule actions - updateScheduleAfterCare helper cancels old reminder, schedules new one - getCareLogs/getCareSchedules/getOverduePlants/getCareDueSoon queries - CareLogForm, CareScheduleEditor, CareHistoryList components - Plant detail Care tab wired up with schedule editor + log form + history - Log plant care quick-add added to garden manifest
This commit is contained in:
@@ -6,7 +6,9 @@ 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";
|
||||
import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders";
|
||||
import { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
|
||||
import { updateScheduleAfterCare } from "./care-schedule";
|
||||
|
||||
const containerInput = z.object({
|
||||
name: z.string().trim().min(1).max(120),
|
||||
@@ -305,3 +307,177 @@ async function assertCanAccessPlant(id: string, householdId: string) {
|
||||
|
||||
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 },
|
||||
});
|
||||
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");
|
||||
|
||||
await cancelReminder("garden.schedule", schedule.id);
|
||||
if (enabled) {
|
||||
await scheduleReminder({
|
||||
householdId: household.id,
|
||||
entityType: "garden.schedule",
|
||||
entityId: schedule.id,
|
||||
fireAt: nextDueAt,
|
||||
createdBy: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
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) {
|
||||
await scheduleReminder({
|
||||
householdId: household.id,
|
||||
entityType: "garden.schedule",
|
||||
entityId: parsed.id,
|
||||
fireAt: row.nextDueAt,
|
||||
createdBy: user.id,
|
||||
});
|
||||
}
|
||||
|
||||
revalidatePath(`/garden/plants/${row.plantId}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user