Implement activity log (task 22)

- activity_log table in _core/schema.ts with household+created_at index;
  migration 0008_activity_log.sql applied
- logActivity() in _core/activity.ts reads current session and inserts a row
- ActivityLogEntry type + renderActivity?(entry): string added to
  EntityTypeRegistration so modules declare human-readable labels without
  any if/else branches in the widget
- core.activity widget replaced with a real async server component that
  queries the last 20 rows and renders via the registry
- logActivity wired into every create/update/delete in calendar, lists,
  and notes server actions; getAuthorizedItem also returns text so
  toggle/delete can include item text in the payload
- pnpm typecheck, lint, build, and all 4 E2E specs pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
ginnoir
2026-05-06 13:57:03 -05:00
co-authored by Claude Sonnet 4.6
parent 1133fa8aac
commit 28475e483d
13 changed files with 182 additions and 11 deletions
+12
View File
@@ -101,6 +101,12 @@ const manifest: ModuleManifest = {
share: { canShare: true, defaultCapabilities: ["read"] },
search: { search: searchCalendars },
resolveUrl: (id) => `/calendar?id=${id}`,
renderActivity: (entry) => {
const name = entry.payload?.name as string | undefined;
if (entry.action === "create") return `Created calendar${name ? ` "${name}"` : ""}`;
if (entry.action === "delete") return "Deleted calendar";
return `Updated calendar${name ? ` "${name}"` : ""}`;
},
},
{
type: "calendar.event",
@@ -109,6 +115,12 @@ const manifest: ModuleManifest = {
reminder: { canRemind: true },
search: { search: searchEvents },
resolveUrl: (id) => `/calendar/events/${id}`,
renderActivity: (entry) => {
const title = entry.payload?.title as string | undefined;
if (entry.action === "create") return `Created event${title ? ` "${title}"` : ""}`;
if (entry.action === "delete") return "Deleted event";
return `Updated event${title ? ` "${title}"` : ""}`;
},
},
],
dashboardWidgets: [
+8
View File
@@ -5,6 +5,7 @@ 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 { calendarEvents, calendars } from "../schema";
import { canSeeCalendar } from "./queries";
@@ -55,6 +56,7 @@ export async function createCalendar(input: z.input<typeof calendarInput>) {
.returning();
if (!calendar) throw new Error("Calendar was not created");
await logActivity({ entityType: "calendar.calendar", entityId: calendar.id, action: "create", payload: { name: calendar.name } });
revalidatePath("/calendar");
return calendar;
}
@@ -68,6 +70,7 @@ export async function renameCalendar(input: { id: string; name: string }) {
.set({ name: parsed.name, updatedAt: new Date() })
.where(eq(calendars.id, parsed.id));
await logActivity({ entityType: "calendar.calendar", entityId: parsed.id, action: "update", payload: { name: parsed.name } });
revalidatePath("/calendar");
}
@@ -85,6 +88,7 @@ export async function setCalendarVisibility(input: {
.set({ visibility: parsed.visibility, updatedAt: new Date() })
.where(eq(calendars.id, parsed.id));
await logActivity({ entityType: "calendar.calendar", entityId: parsed.id, action: "update", payload: { visibility: parsed.visibility } });
revalidatePath("/calendar");
}
@@ -104,6 +108,7 @@ export async function deleteCalendar(input: { id: string }) {
const { user } = await getCurrentSession();
const parsed = z.object({ id: z.string().uuid() }).parse(input);
await assertOwnsCalendar(user.id, parsed.id);
await logActivity({ entityType: "calendar.calendar", entityId: parsed.id, action: "delete" });
await db.delete(calendars).where(eq(calendars.id, parsed.id));
revalidatePath("/calendar");
}
@@ -124,6 +129,7 @@ export async function createEvent(input: z.input<typeof eventInput>) {
.returning();
if (!event) throw new Error("Event was not created");
await logActivity({ entityType: "calendar.event", entityId: event.id, action: "create", payload: { title: event.title } });
revalidatePath("/calendar");
return {
...event,
@@ -159,6 +165,7 @@ export async function updateEvent(input: { id: string } & Partial<z.input<typeof
})
.where(eq(calendarEvents.id, parsed.id));
await logActivity({ entityType: "calendar.event", entityId: parsed.id, action: "update", payload: parsed.title ? { title: parsed.title } : undefined });
revalidatePath("/calendar");
}
@@ -173,6 +180,7 @@ export async function deleteEvent(input: { id: string }) {
if (!existing) return;
if (!(await canSeeCalendar(user.id, existing.calendarId))) throw new Error("Forbidden");
await logActivity({ entityType: "calendar.event", entityId: parsed.id, action: "delete" });
await db.delete(calendarEvents).where(eq(calendarEvents.id, parsed.id));
revalidatePath("/calendar");
}