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:
co-authored by
Claude Sonnet 4.6
parent
1133fa8aac
commit
28475e483d
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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} />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user