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
+20
View File
@@ -0,0 +1,20 @@
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import { activityLog } from "./schema";
export async function logActivity(input: {
entityType: string;
entityId: string;
action: string;
payload?: Record<string, unknown>;
}): Promise<void> {
const { user, household } = await getCurrentSession();
await db.insert(activityLog).values({
householdId: household.id,
entityType: input.entityType,
entityId: input.entityId,
actorId: user.id,
action: input.action,
payload: input.payload ?? null,
});
}
+2
View File
@@ -8,6 +8,8 @@ export type {
ReminderCapabilities,
SearchAdapter,
SearchResult,
ActivityLogEntry,
} from "./module";
export { registerModule, getRegistry, getEntityType, getWidget, getQuickAdds } from "./registry";
export type { QuickAddItem, SerializedQuickAddItem } from "./registry";
export { logActivity } from "./activity";
+48 -10
View File
@@ -1,5 +1,49 @@
import type { ModuleManifest } from "./module";
import { desc, eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/lib/db";
import { getCurrentSession } from "@/lib/session";
import type { ActivityLogEntry, ModuleManifest } from "./module";
import { getEntityType } from "./registry";
import { activityLog } from "./schema";
const activityConfigSchema = z.object({
limit: z.number().int().min(1).max(50).optional(),
});
async function ActivityWidget({ config }: { config: unknown }) {
const parsed = activityConfigSchema.parse(config);
const { household } = await getCurrentSession();
const entries = await db
.select()
.from(activityLog)
.where(eq(activityLog.householdId, household.id))
.orderBy(desc(activityLog.createdAt))
.limit(parsed.limit ?? 20);
if (entries.length === 0) {
return <p className="text-sm text-muted-foreground">No recent activity</p>;
}
return (
<ul className="space-y-2">
{entries.map((entry) => {
const reg = getEntityType(entry.entityType);
const description =
reg?.renderActivity?.(entry as ActivityLogEntry) ??
`${entry.action} ${entry.entityType}`;
return (
<li key={entry.id} className="flex items-start gap-2 text-sm">
<span className="mt-0.5 shrink-0 text-xs text-muted-foreground">
{entry.createdAt.toLocaleDateString(undefined, { month: "short", day: "numeric" })}
</span>
<span className="leading-snug">{description}</span>
</li>
);
})}
</ul>
);
}
const coreManifest: ModuleManifest = {
id: "_core",
@@ -14,16 +58,10 @@ const coreManifest: ModuleManifest = {
defaultSize: { w: 4, h: 3 },
minSize: { w: 3, h: 2 },
defaultPriority: 50,
configSchema: z.object({
limit: z.number().int().min(1).max(50).optional(),
}),
defaultConfig: { limit: 10 },
configSchema: activityConfigSchema,
defaultConfig: { limit: 20 },
resolveConfigOptions: async () => undefined,
render: () => (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Activity log coming in task 22
</div>
),
render: (props) => <ActivityWidget {...props} />,
},
],
};
+12
View File
@@ -1,6 +1,17 @@
import type { ReactNode } from "react";
import type { ZodType } from "zod";
export type ActivityLogEntry = {
id: string;
householdId: string;
entityType: string;
entityId: string;
actorId: string;
action: string;
payload: Record<string, unknown> | null;
createdAt: Date;
};
export type ShareCapabilities = {
canShare: boolean;
defaultCapabilities?: string[];
@@ -58,6 +69,7 @@ export type EntityTypeRegistration = {
search?: SearchAdapter;
resolveUrl: (id: string) => string;
loadForShare?: (id: string) => Promise<unknown>;
renderActivity?: (entry: ActivityLogEntry) => string;
};
export type ModuleManifest = {
+19
View File
@@ -87,6 +87,25 @@ export const householdMembers = pgTable(
(t) => [primaryKey({ columns: [t.householdId, t.userId] })],
);
export const activityLog = pgTable(
"activity_log",
{
id: uuid("id").primaryKey().defaultRandom(),
householdId: uuid("household_id")
.notNull()
.references(() => households.id, { onDelete: "cascade" }),
entityType: text("entity_type").notNull(),
entityId: uuid("entity_id").notNull(),
actorId: uuid("actor_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
action: text("action").notNull(),
payload: jsonb("payload").$type<Record<string, unknown> | null>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(t) => [index("activity_log_household_created_idx").on(t.householdId, t.createdAt)],
);
export const reminders = pgTable(
"reminders",
{