From 7f2c5a44ddba3ec8b4dffe895306b30cf6d14dc6 Mon Sep 17 00:00:00 2001 From: ginnoir Date: Mon, 1 Jun 2026 23:58:00 -0500 Subject: [PATCH] feat: garden care reminder bodies, task sync, upload cleanup - 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" --- drizzle/0016_garden_improvements.sql | 6 +++ src/app/api/uploads/route.ts | 11 +--- src/modules/_core/registry.ts | 20 ++++++++ src/modules/_core/reminders.ts | 16 ++++-- src/modules/_core/schema.ts | 2 + src/modules/garden/manifest.tsx | 44 ++++++++++++++++ src/modules/garden/server/actions.ts | 60 +++++++++++++++++----- src/modules/garden/server/care-schedule.ts | 11 +++- src/modules/garden/server/care-utils.ts | 16 ++++++ src/modules/garden/server/lists-bridge.ts | 19 +++++++ src/modules/lists/schema.ts | 12 ++++- src/modules/lists/server/actions.ts | 16 +++++- 12 files changed, 205 insertions(+), 28 deletions(-) create mode 100644 drizzle/0016_garden_improvements.sql create mode 100644 src/modules/garden/server/care-utils.ts diff --git a/drizzle/0016_garden_improvements.sql b/drizzle/0016_garden_improvements.sql new file mode 100644 index 0000000..a1bc31b --- /dev/null +++ b/drizzle/0016_garden_improvements.sql @@ -0,0 +1,6 @@ +-- Add title/body to reminders for rich notification bodies +ALTER TABLE reminders ADD COLUMN IF NOT EXISTS title text; +ALTER TABLE reminders ADD COLUMN IF NOT EXISTS body text; + +-- Add metadata to list_items for garden task linking +ALTER TABLE list_items ADD COLUMN IF NOT EXISTS metadata jsonb; diff --git a/src/app/api/uploads/route.ts b/src/app/api/uploads/route.ts index af70b57..4e63195 100644 --- a/src/app/api/uploads/route.ts +++ b/src/app/api/uploads/route.ts @@ -37,7 +37,7 @@ export async function POST(request: Request) { } if (file.size > MAX_FILE_SIZE) { - return NextResponse.json({ error: "File exceeds 5 MB limit" }, { status: 413 }); + return NextResponse.json({ error: "File exceeds 100 MB limit" }, { status: 413 }); } const rawExt = file.name.split(".").pop() ?? "bin"; @@ -54,15 +54,8 @@ export async function POST(request: Request) { "Content-Type": file.type, }); } catch (err) { - const msg = err instanceof Error ? err.message : String(err); console.error("Upload failed", err); - return NextResponse.json( - { - error: "Upload service unavailable", - detail: process.env.NODE_ENV !== "production" ? msg : undefined, - }, - { status: 503 }, - ); + return NextResponse.json({ error: "Upload service unavailable" }, { status: 503 }); } return NextResponse.json({ url: `/api/uploads/${key}` }); diff --git a/src/modules/_core/registry.ts b/src/modules/_core/registry.ts index 0193c15..d5368b5 100644 --- a/src/modules/_core/registry.ts +++ b/src/modules/_core/registry.ts @@ -73,6 +73,26 @@ export function getWidgetMetas(): SerializedWidgetMeta[] { ); } +// ─── Item toggle hooks ──────────────────────────────────────────────────────── + +type ItemTogglePayload = { + itemId: string; + metadata: Record; + done: boolean; + userId: string; +}; +type ItemToggleHook = (payload: ItemTogglePayload) => Promise; + +const itemToggleHooks = new Map(); + +export function registerItemToggleHook(id: string, hook: ItemToggleHook): void { + itemToggleHooks.set(id, hook); +} + +export async function fireItemToggleHooks(payload: ItemTogglePayload): Promise { + await Promise.allSettled([...itemToggleHooks.values()].map((h) => h(payload))); +} + export function getQuickAdds(): SerializedQuickAddItem[] { return [...modules.values()].flatMap((manifest) => (manifest.quickAdds ?? []).map(({ id, label, icon, url }) => ({ diff --git a/src/modules/_core/reminders.ts b/src/modules/_core/reminders.ts index 6158732..ff83e12 100644 --- a/src/modules/_core/reminders.ts +++ b/src/modules/_core/reminders.ts @@ -13,6 +13,8 @@ export async function scheduleReminder(input: { fireAt: Date; createdBy: string; channel?: string; + title?: string; + body?: string; }) { await db .insert(reminders) @@ -22,12 +24,20 @@ export async function scheduleReminder(input: { entityId: input.entityId, fireAt: input.fireAt, channel: input.channel ?? "auto", + title: input.title ?? null, + body: input.body ?? null, createdBy: input.createdBy, firedAt: null, }) .onConflictDoUpdate({ target: [reminders.entityType, reminders.entityId], - set: { fireAt: input.fireAt, firedAt: null, createdBy: input.createdBy }, + set: { + fireAt: input.fireAt, + title: input.title ?? null, + body: input.body ?? null, + firedAt: null, + createdBy: input.createdBy, + }, }); } @@ -82,8 +92,8 @@ export async function tickReminders() { if (!reminder.createdBy) return; try { await notify(reminder.createdBy, { - title: "Reminder", - body: `You have a reminder`, + title: reminder.title ?? "Reminder", + body: reminder.body ?? "You have a reminder", url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/", channels: ["push", "inapp"], }); diff --git a/src/modules/_core/schema.ts b/src/modules/_core/schema.ts index aec39fd..573703b 100644 --- a/src/modules/_core/schema.ts +++ b/src/modules/_core/schema.ts @@ -166,6 +166,8 @@ export const reminders = pgTable( entityId: uuid("entity_id").notNull(), fireAt: timestamp("fire_at", { withTimezone: true }).notNull(), channel: text("channel").notNull().default("auto"), + title: text("title"), + body: text("body"), firedAt: timestamp("fired_at", { withTimezone: true }), createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), diff --git a/src/modules/garden/manifest.tsx b/src/modules/garden/manifest.tsx index c58e2b9..efc65d3 100644 --- a/src/modules/garden/manifest.tsx +++ b/src/modules/garden/manifest.tsx @@ -1,4 +1,7 @@ import { z } from "zod"; +import { and, eq, sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { registerItemToggleHook } from "../_core/registry"; import type { ModuleManifest, WidgetContext } from "../_core/module"; import { loadContainerForShare, @@ -193,4 +196,45 @@ const gardenManifest: ModuleManifest = { ], }; +import { gardenCareLogs, gardenPlants } from "./schema"; + +registerItemToggleHook("garden.care-task", async ({ metadata, done, userId }) => { + if (!done) return; + const plantId = metadata.gardenPlantId; + const careType = metadata.gardenCareType; + if (typeof plantId !== "string" || typeof careType !== "string") return; + + const [plant] = await db + .select({ householdId: gardenPlants.householdId }) + .from(gardenPlants) + .where(eq(gardenPlants.id, plantId)) + .limit(1); + + if (!plant) return; + + // Avoid duplicate log if care was already logged within the last 10 minutes + const recentLog = await db + .select({ id: gardenCareLogs.id }) + .from(gardenCareLogs) + .where( + and( + eq(gardenCareLogs.plantId, plantId), + eq(gardenCareLogs.careType, careType), + sql`${gardenCareLogs.performedAt} > now() - interval '10 minutes'`, + ), + ) + .limit(1); + + if (recentLog[0]) return; + + await db.insert(gardenCareLogs).values({ + plantId, + householdId: plant.householdId, + careType, + performedBy: userId, + notes: "Logged via task list", + performedAt: new Date(), + }); +}); + export default gardenManifest; diff --git a/src/modules/garden/server/actions.ts b/src/modules/garden/server/actions.ts index a16876a..261eafd 100644 --- a/src/modules/garden/server/actions.ts +++ b/src/modules/garden/server/actions.ts @@ -7,10 +7,13 @@ 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 { addListItem, getList, listLists } from "./lists-bridge"; +import { addGardenCareTask, getList, listLists } from "./lists-bridge"; const containerInput = z.object({ name: z.string().trim().min(1).max(120), @@ -347,6 +350,29 @@ export async function logCare(input: z.input) { 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; } @@ -416,6 +442,12 @@ export async function upsertCareSchedule(input: z.input = { - watering: "Water", - fertilizing: "Fertilize", - repotting: "Repot", - pruning: "Prune", - }; - const verb = verbs[careType]; - return verb ? `${verb} ${plantName}` : `${careType} — ${plantName}`; -} - const scheduleOnCalendarInput = z.object({ scheduleId: z.string().uuid(), calendarId: z.string().uuid(), @@ -550,6 +581,7 @@ export async function pushOverdueToTaskList(): Promise<{ added: number }> { const overduePairs = await db .select({ + plantId: gardenPlants.id, plantName: gardenPlants.name, careType: gardenCareSchedules.careType, nextDueAt: gardenCareSchedules.nextDueAt, @@ -582,11 +614,13 @@ export async function pushOverdueToTaskList(): Promise<{ added: number }> { ? Math.abs(Math.floor((Date.now() - pair.nextDueAt.getTime()) / (1000 * 60 * 60 * 24))) : 0; - await addListItem({ + 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++; diff --git a/src/modules/garden/server/care-schedule.ts b/src/modules/garden/server/care-schedule.ts index b80cc04..b37782c 100644 --- a/src/modules/garden/server/care-schedule.ts +++ b/src/modules/garden/server/care-schedule.ts @@ -1,7 +1,8 @@ import { and, eq } from "drizzle-orm"; import { db } from "@/lib/db"; import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders"; -import { gardenCareSchedules } from "../schema"; +import { gardenCareSchedules, gardenPlants } from "../schema"; +import { buildCareReminderBody } from "./care-utils"; export async function updateScheduleAfterCare( plantId: string, @@ -27,6 +28,12 @@ export async function updateScheduleAfterCare( .set({ lastPerformedAt: now, nextDueAt, updatedAt: now }) .where(eq(gardenCareSchedules.id, schedule.id)); + const [plant] = await db + .select({ name: gardenPlants.name }) + .from(gardenPlants) + .where(eq(gardenPlants.id, plantId)) + .limit(1); + await cancelReminder("garden.schedule", schedule.id); await scheduleReminder({ householdId, @@ -34,5 +41,7 @@ export async function updateScheduleAfterCare( entityId: schedule.id, fireAt: nextDueAt, createdBy: userId, + title: "Garden care reminder", + body: plant ? buildCareReminderBody(careType, plant.name) : undefined, }); } diff --git a/src/modules/garden/server/care-utils.ts b/src/modules/garden/server/care-utils.ts new file mode 100644 index 0000000..14c28ef --- /dev/null +++ b/src/modules/garden/server/care-utils.ts @@ -0,0 +1,16 @@ +const CARE_VERBS: Record = { + watering: "Water", + fertilizing: "Fertilize", + repotting: "Repot", + pruning: "Prune", +}; + +export function buildCareTitle(careType: string, plantName: string): string { + const verb = CARE_VERBS[careType]; + return verb ? `${verb} ${plantName}` : `${careType} — ${plantName}`; +} + +export function buildCareReminderBody(careType: string, plantName: string): string { + const verb = CARE_VERBS[careType]?.toLowerCase() ?? careType; + return `Time to ${verb} ${plantName}`; +} diff --git a/src/modules/garden/server/lists-bridge.ts b/src/modules/garden/server/lists-bridge.ts index b96676d..19b9a23 100644 --- a/src/modules/garden/server/lists-bridge.ts +++ b/src/modules/garden/server/lists-bridge.ts @@ -1,2 +1,21 @@ export { addItem as addListItem } from "@/modules/lists/server/actions"; export { getList, listLists } from "@/modules/lists/server/queries"; + +import { addItem } from "@/modules/lists/server/actions"; + +export async function addGardenCareTask(input: { + listId: string; + text: string; + notes?: string | null; + dueAt?: Date | null; + gardenPlantId: string; + gardenCareType: string; +}) { + return addItem({ + listId: input.listId, + text: input.text, + notes: input.notes ?? null, + dueAt: input.dueAt ?? null, + metadata: { gardenPlantId: input.gardenPlantId, gardenCareType: input.gardenCareType }, + }); +} diff --git a/src/modules/lists/schema.ts b/src/modules/lists/schema.ts index 5b56d68..921c4ae 100644 --- a/src/modules/lists/schema.ts +++ b/src/modules/lists/schema.ts @@ -1,4 +1,13 @@ -import { index, integer, pgTable, text, timestamp, uuid, boolean } from "drizzle-orm/pg-core"; +import { + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uuid, + boolean, +} from "drizzle-orm/pg-core"; import { households, users } from "../_core/schema"; export const lists = pgTable( @@ -33,6 +42,7 @@ export const listItems = pgTable( dueAt: timestamp("due_at", { withTimezone: true }), assigneeId: uuid("assignee_id").references(() => users.id, { onDelete: "set null" }), position: integer("position").notNull().default(0), + metadata: jsonb("metadata").$type>(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, diff --git a/src/modules/lists/server/actions.ts b/src/modules/lists/server/actions.ts index c210003..4d6c9ef 100644 --- a/src/modules/lists/server/actions.ts +++ b/src/modules/lists/server/actions.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { db } from "@/lib/db"; import { getCurrentSession } from "@/lib/session"; import { logActivity } from "@/modules/_core/activity"; +import { fireItemToggleHooks } from "@/modules/_core/registry"; import { listItems, lists } from "../schema"; import { getOrCreateDefaultList } from "./defaults"; import { canAccessList, getList } from "./queries"; @@ -23,6 +24,7 @@ const itemInput = z.object({ notes: z.string().trim().max(2000).nullable().optional(), dueAt: z.coerce.date().nullable().optional(), assigneeId: z.string().uuid().nullable().optional(), + metadata: z.record(z.string(), z.unknown()).nullable().optional(), }); const updateItemInput = z.object({ @@ -103,6 +105,7 @@ export async function addItem(input: z.input) { notes: parsed.notes || null, dueAt: parsed.dueAt ?? null, assigneeId: parsed.assigneeId ?? null, + metadata: parsed.metadata ?? null, position: (positionRow?.maxPosition ?? -1) + 1, }) .returning(); @@ -133,7 +136,7 @@ export async function addItemToDefaultList(input: { type: string; text: string } export async function toggleItem(input: { id: string; done?: boolean }) { const parsed = z.object({ id: z.string().uuid(), done: z.boolean().optional() }).parse(input); - const { household } = await getCurrentSession(); + const { household, user } = await getCurrentSession(); const existing = await getAuthorizedItem(parsed.id, household.id); const done = parsed.done ?? !existing.done; @@ -148,6 +151,16 @@ export async function toggleItem(input: { id: string; done?: boolean }) { action: "toggle", payload: { done, text: existing.text }, }); + + if (done && existing.metadata) { + await fireItemToggleHooks({ + itemId: parsed.id, + metadata: existing.metadata as Record, + done, + userId: user.id, + }); + } + revalidatePath(`/lists/${existing.listId}`); await notifyListChanged(existing.listId); return getList(existing.listId); @@ -229,6 +242,7 @@ async function getAuthorizedItem(itemId: string, householdId: string) { listId: listItems.listId, done: listItems.done, text: listItems.text, + metadata: listItems.metadata, }) .from(listItems) .innerJoin(lists, eq(listItems.listId, lists.id))