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:
@@ -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;
|
||||||
@@ -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}` });
|
||||||
|
|||||||
@@ -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,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"],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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++;
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
Reference in New Issue
Block a user