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"
This commit is contained in:
ginnoir
2026-06-01 23:58:00 -05:00
parent dd980c6932
commit 7f2c5a44dd
12 changed files with 205 additions and 28 deletions
+6
View File
@@ -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;
+2 -9
View File
@@ -37,7 +37,7 @@ export async function POST(request: Request) {
} }
if (file.size > MAX_FILE_SIZE) { 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"; const rawExt = file.name.split(".").pop() ?? "bin";
@@ -54,15 +54,8 @@ export async function POST(request: Request) {
"Content-Type": file.type, "Content-Type": file.type,
}); });
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.error("Upload failed", err); console.error("Upload failed", err);
return NextResponse.json( return NextResponse.json({ error: "Upload service unavailable" }, { status: 503 });
{
error: "Upload service unavailable",
detail: process.env.NODE_ENV !== "production" ? msg : undefined,
},
{ status: 503 },
);
} }
return NextResponse.json({ url: `/api/uploads/${key}` }); return NextResponse.json({ url: `/api/uploads/${key}` });
+20
View File
@@ -73,6 +73,26 @@ export function getWidgetMetas(): SerializedWidgetMeta[] {
); );
} }
// ─── Item toggle hooks ────────────────────────────────────────────────────────
type ItemTogglePayload = {
itemId: string;
metadata: Record<string, unknown>;
done: boolean;
userId: string;
};
type ItemToggleHook = (payload: ItemTogglePayload) => Promise<void>;
const itemToggleHooks = new Map<string, ItemToggleHook>();
export function registerItemToggleHook(id: string, hook: ItemToggleHook): void {
itemToggleHooks.set(id, hook);
}
export async function fireItemToggleHooks(payload: ItemTogglePayload): Promise<void> {
await Promise.allSettled([...itemToggleHooks.values()].map((h) => h(payload)));
}
export function getQuickAdds(): SerializedQuickAddItem[] { export function getQuickAdds(): SerializedQuickAddItem[] {
return [...modules.values()].flatMap((manifest) => return [...modules.values()].flatMap((manifest) =>
(manifest.quickAdds ?? []).map(({ id, label, icon, url }) => ({ (manifest.quickAdds ?? []).map(({ id, label, icon, url }) => ({
+13 -3
View File
@@ -13,6 +13,8 @@ export async function scheduleReminder(input: {
fireAt: Date; fireAt: Date;
createdBy: string; createdBy: string;
channel?: string; channel?: string;
title?: string;
body?: string;
}) { }) {
await db await db
.insert(reminders) .insert(reminders)
@@ -22,12 +24,20 @@ export async function scheduleReminder(input: {
entityId: input.entityId, entityId: input.entityId,
fireAt: input.fireAt, fireAt: input.fireAt,
channel: input.channel ?? "auto", channel: input.channel ?? "auto",
title: input.title ?? null,
body: input.body ?? null,
createdBy: input.createdBy, createdBy: input.createdBy,
firedAt: null, firedAt: null,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [reminders.entityType, reminders.entityId], 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; if (!reminder.createdBy) return;
try { try {
await notify(reminder.createdBy, { await notify(reminder.createdBy, {
title: "Reminder", title: reminder.title ?? "Reminder",
body: `You have a reminder`, body: reminder.body ?? "You have a reminder",
url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/", url: reminder.entityType === "notes.note" ? `/notes/${reminder.entityId}` : "/",
channels: ["push", "inapp"], channels: ["push", "inapp"],
}); });
+2
View File
@@ -166,6 +166,8 @@ export const reminders = pgTable(
entityId: uuid("entity_id").notNull(), entityId: uuid("entity_id").notNull(),
fireAt: timestamp("fire_at", { withTimezone: true }).notNull(), fireAt: timestamp("fire_at", { withTimezone: true }).notNull(),
channel: text("channel").notNull().default("auto"), channel: text("channel").notNull().default("auto"),
title: text("title"),
body: text("body"),
firedAt: timestamp("fired_at", { withTimezone: true }), firedAt: timestamp("fired_at", { withTimezone: true }),
createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }), createdBy: uuid("created_by").references(() => users.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
+44
View File
@@ -1,4 +1,7 @@
import { z } from "zod"; 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 type { ModuleManifest, WidgetContext } from "../_core/module";
import { import {
loadContainerForShare, 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; export default gardenManifest;
+47 -13
View File
@@ -7,10 +7,13 @@ import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session"; import { getCurrentSession } from "@/lib/session";
import { logActivity } from "@/modules/_core/activity"; import { logActivity } from "@/modules/_core/activity";
import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders"; 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 { gardenCareLogs, gardenCareSchedules, gardenContainers, gardenPlants } from "../schema";
import { createCalendarEvent } from "./calendar-bridge"; import { createCalendarEvent } from "./calendar-bridge";
import { buildCareReminderBody, buildCareTitle } from "./care-utils";
import { updateScheduleAfterCare } from "./care-schedule"; import { updateScheduleAfterCare } from "./care-schedule";
import { addListItem, getList, listLists } from "./lists-bridge"; import { addGardenCareTask, getList, listLists } from "./lists-bridge";
const containerInput = z.object({ const containerInput = z.object({
name: z.string().trim().min(1).max(120), name: z.string().trim().min(1).max(120),
@@ -347,6 +350,29 @@ export async function logCare(input: z.input<typeof careLogInput>) {
action: "update", action: "update",
payload: { careType: parsed.careType }, 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}`); revalidatePath(`/garden/plants/${parsed.plantId}`);
return log; return log;
} }
@@ -416,6 +442,12 @@ export async function upsertCareSchedule(input: z.input<typeof careScheduleInput
if (!schedule) throw new Error("Schedule was not created"); 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); await cancelReminder("garden.schedule", schedule.id);
if (enabled) { if (enabled) {
await scheduleReminder({ await scheduleReminder({
@@ -424,6 +456,8 @@ export async function upsertCareSchedule(input: z.input<typeof careScheduleInput
entityId: schedule.id, entityId: schedule.id,
fireAt: nextDueAt, fireAt: nextDueAt,
createdBy: user.id, createdBy: user.id,
title: "Garden care reminder",
body: plantRow ? buildCareReminderBody(parsed.careType, plantRow.name) : undefined,
}); });
} }
@@ -472,12 +506,20 @@ export async function toggleCareSchedule(input: { id: string; enabled: boolean }
await cancelReminder("garden.schedule", parsed.id); await cancelReminder("garden.schedule", parsed.id);
if (parsed.enabled && row.nextDueAt) { 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({ await scheduleReminder({
householdId: household.id, householdId: household.id,
entityType: "garden.schedule", entityType: "garden.schedule",
entityId: parsed.id, entityId: parsed.id,
fireAt: row.nextDueAt, fireAt: row.nextDueAt,
createdBy: user.id, createdBy: user.id,
title: "Garden care reminder",
body: togglePlantRow ? buildCareReminderBody(row.careType, togglePlantRow.name) : undefined,
}); });
} }
@@ -486,17 +528,6 @@ export async function toggleCareSchedule(input: { id: string; enabled: boolean }
// ─── Calendar integration ───────────────────────────────────────────────────── // ─── Calendar integration ─────────────────────────────────────────────────────
function buildCareTitle(careType: string, plantName: string): string {
const verbs: Record<string, string> = {
watering: "Water",
fertilizing: "Fertilize",
repotting: "Repot",
pruning: "Prune",
};
const verb = verbs[careType];
return verb ? `${verb} ${plantName}` : `${careType}${plantName}`;
}
const scheduleOnCalendarInput = z.object({ const scheduleOnCalendarInput = z.object({
scheduleId: z.string().uuid(), scheduleId: z.string().uuid(),
calendarId: z.string().uuid(), calendarId: z.string().uuid(),
@@ -550,6 +581,7 @@ export async function pushOverdueToTaskList(): Promise<{ added: number }> {
const overduePairs = await db const overduePairs = await db
.select({ .select({
plantId: gardenPlants.id,
plantName: gardenPlants.name, plantName: gardenPlants.name,
careType: gardenCareSchedules.careType, careType: gardenCareSchedules.careType,
nextDueAt: gardenCareSchedules.nextDueAt, 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))) ? Math.abs(Math.floor((Date.now() - pair.nextDueAt.getTime()) / (1000 * 60 * 60 * 24)))
: 0; : 0;
await addListItem({ await addGardenCareTask({
listId: taskList.id, listId: taskList.id,
text: title, text: title,
notes: `Overdue by ${daysOverdue} day(s)`, notes: `Overdue by ${daysOverdue} day(s)`,
dueAt: pair.nextDueAt, dueAt: pair.nextDueAt,
gardenPlantId: pair.plantId,
gardenCareType: pair.careType,
}); });
existingTexts.add(title); existingTexts.add(title);
added++; added++;
+10 -1
View File
@@ -1,7 +1,8 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { cancelReminder, scheduleReminder } from "@/modules/_core/reminders"; 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( export async function updateScheduleAfterCare(
plantId: string, plantId: string,
@@ -27,6 +28,12 @@ export async function updateScheduleAfterCare(
.set({ lastPerformedAt: now, nextDueAt, updatedAt: now }) .set({ lastPerformedAt: now, nextDueAt, updatedAt: now })
.where(eq(gardenCareSchedules.id, schedule.id)); .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 cancelReminder("garden.schedule", schedule.id);
await scheduleReminder({ await scheduleReminder({
householdId, householdId,
@@ -34,5 +41,7 @@ export async function updateScheduleAfterCare(
entityId: schedule.id, entityId: schedule.id,
fireAt: nextDueAt, fireAt: nextDueAt,
createdBy: userId, createdBy: userId,
title: "Garden care reminder",
body: plant ? buildCareReminderBody(careType, plant.name) : undefined,
}); });
} }
+16
View File
@@ -0,0 +1,16 @@
const CARE_VERBS: Record<string, string> = {
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}`;
}
+19
View File
@@ -1,2 +1,21 @@
export { addItem as addListItem } from "@/modules/lists/server/actions"; export { addItem as addListItem } from "@/modules/lists/server/actions";
export { getList, listLists } from "@/modules/lists/server/queries"; 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 },
});
}
+11 -1
View File
@@ -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"; import { households, users } from "../_core/schema";
export const lists = pgTable( export const lists = pgTable(
@@ -33,6 +42,7 @@ export const listItems = pgTable(
dueAt: timestamp("due_at", { withTimezone: true }), dueAt: timestamp("due_at", { withTimezone: true }),
assigneeId: uuid("assignee_id").references(() => users.id, { onDelete: "set null" }), assigneeId: uuid("assignee_id").references(() => users.id, { onDelete: "set null" }),
position: integer("position").notNull().default(0), position: integer("position").notNull().default(0),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
}, },
+15 -1
View File
@@ -6,6 +6,7 @@ import { z } from "zod";
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session"; import { getCurrentSession } from "@/lib/session";
import { logActivity } from "@/modules/_core/activity"; import { logActivity } from "@/modules/_core/activity";
import { fireItemToggleHooks } from "@/modules/_core/registry";
import { listItems, lists } from "../schema"; import { listItems, lists } from "../schema";
import { getOrCreateDefaultList } from "./defaults"; import { getOrCreateDefaultList } from "./defaults";
import { canAccessList, getList } from "./queries"; import { canAccessList, getList } from "./queries";
@@ -23,6 +24,7 @@ const itemInput = z.object({
notes: z.string().trim().max(2000).nullable().optional(), notes: z.string().trim().max(2000).nullable().optional(),
dueAt: z.coerce.date().nullable().optional(), dueAt: z.coerce.date().nullable().optional(),
assigneeId: z.string().uuid().nullable().optional(), assigneeId: z.string().uuid().nullable().optional(),
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
}); });
const updateItemInput = z.object({ const updateItemInput = z.object({
@@ -103,6 +105,7 @@ export async function addItem(input: z.input<typeof itemInput>) {
notes: parsed.notes || null, notes: parsed.notes || null,
dueAt: parsed.dueAt ?? null, dueAt: parsed.dueAt ?? null,
assigneeId: parsed.assigneeId ?? null, assigneeId: parsed.assigneeId ?? null,
metadata: parsed.metadata ?? null,
position: (positionRow?.maxPosition ?? -1) + 1, position: (positionRow?.maxPosition ?? -1) + 1,
}) })
.returning(); .returning();
@@ -133,7 +136,7 @@ export async function addItemToDefaultList(input: { type: string; text: string }
export async function toggleItem(input: { id: string; done?: boolean }) { export async function toggleItem(input: { id: string; done?: boolean }) {
const parsed = z.object({ id: z.string().uuid(), done: z.boolean().optional() }).parse(input); 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 existing = await getAuthorizedItem(parsed.id, household.id);
const done = parsed.done ?? !existing.done; const done = parsed.done ?? !existing.done;
@@ -148,6 +151,16 @@ export async function toggleItem(input: { id: string; done?: boolean }) {
action: "toggle", action: "toggle",
payload: { done, text: existing.text }, payload: { done, text: existing.text },
}); });
if (done && existing.metadata) {
await fireItemToggleHooks({
itemId: parsed.id,
metadata: existing.metadata as Record<string, unknown>,
done,
userId: user.id,
});
}
revalidatePath(`/lists/${existing.listId}`); revalidatePath(`/lists/${existing.listId}`);
await notifyListChanged(existing.listId); await notifyListChanged(existing.listId);
return getList(existing.listId); return getList(existing.listId);
@@ -229,6 +242,7 @@ async function getAuthorizedItem(itemId: string, householdId: string) {
listId: listItems.listId, listId: listItems.listId,
done: listItems.done, done: listItems.done,
text: listItems.text, text: listItems.text,
metadata: listItems.metadata,
}) })
.from(listItems) .from(listItems)
.innerJoin(lists, eq(listItems.listId, lists.id)) .innerJoin(lists, eq(listItems.listId, lists.id))