Phase 4: activity log — who did what in the workspace
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled

- activity_log table (sqlite + pg, structurally identical; schema-parity
  covers it). Plain-text columns, no FKs — an append-only trail that must
  outlive the projects/users it references, so target_label snapshots a
  human-readable name at write time.
- ActivityRepository: record() (fire-and-forget, never breaks the caller) +
  list() (org-scoped, actor/action filters, keyset pagination) + listActors().
- Recording wired into the mutations worth tracking: project
  create/archive/restore/domain, audit start, team user create/remove/
  password-reset, invitation sent.
- getActivityLog / getActivityActors server functions (owner/admin gated) +
  Settings → Activity tab (ActivityLogView: filter by user & action, load
  more).
- Migration: drizzle/0045_*, drizzle-pg/0023_*. The pipeline does not run
  migrations — see docs/SELF_HOSTING_TEAM_MODE.md step 5 for the one-time
  `drizzle-kit migrate` on the server. Writes fail silently until the table
  exists.

tsc / oxlint / knip clean. New ActivityRepository.test.ts (4) + schema-parity
picks up the new table; suite otherwise unchanged (pre-existing samSkills
CRLF failure only).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-09-09 16:00:09 -04:00
parent 29bda614a4
commit ce75d45141
24 changed files with 8836 additions and 6 deletions

View File

@ -69,6 +69,25 @@ Removing a user drops their membership and signs them out everywhere. Their
`user` row is kept so past activity still attributes correctly; re-adding them
issues a fresh password.
## 5. Activity log
**Settings → Activity** (owner/admin only) shows who did what — projects
created/archived, site audits started, users added/removed, invitations sent —
filterable by user and action.
It writes to a new `activity_log` table, so **run the migration once** after
deploying:
```sh
cd /home/dev/DOCKER/OPEN-SEO/open-seo
export $(grep -E '^DATABASE_URL=' .env | xargs)
pnpm exec drizzle-kit migrate --config drizzle-pg.config.ts
pm2 restart OPEN-SEO
```
Until the table exists, the writes fail silently (logged to the console) and the
app keeps working; the Activity tab just shows nothing.
## Notes
- Password reset by email is not available in `team` mode. The owner/admins

View File

@ -0,0 +1,16 @@
CREATE TABLE "activity_log" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"actor_user_id" text NOT NULL,
"actor_email" text NOT NULL,
"action" text NOT NULL,
"target_type" text,
"target_id" text,
"target_label" text,
"metadata" text,
"ip_address" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE INDEX "activity_log_org_created_at_idx" ON "activity_log" USING btree ("organization_id","created_at");--> statement-breakpoint
CREATE INDEX "activity_log_org_actor_idx" ON "activity_log" USING btree ("organization_id","actor_user_id");

File diff suppressed because it is too large Load Diff

View File

@ -162,6 +162,13 @@
"when": 1787773500453,
"tag": "0022_third_supernaut",
"breakpoints": true
},
{
"idx": 23,
"version": "7",
"when": 1788983569510,
"tag": "0023_clever_prowler",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,16 @@
CREATE TABLE `activity_log` (
`id` text PRIMARY KEY NOT NULL,
`organization_id` text NOT NULL,
`actor_user_id` text NOT NULL,
`actor_email` text NOT NULL,
`action` text NOT NULL,
`target_type` text,
`target_id` text,
`target_label` text,
`metadata` text,
`ip_address` text,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE INDEX `activity_log_org_created_at_idx` ON `activity_log` (`organization_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `activity_log_org_actor_idx` ON `activity_log` (`organization_id`,`actor_user_id`);

File diff suppressed because it is too large Load Diff

View File

@ -316,6 +316,13 @@
"when": 1787773498579,
"tag": "0044_outstanding_sage",
"breakpoints": true
},
{
"idx": 45,
"version": "6",
"when": 1788983568067,
"tag": "0045_noisy_ghost_rider",
"breakpoints": true
}
]
}

View File

@ -0,0 +1,158 @@
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { getActivityActors, getActivityLog } from "@/serverFunctions/activity";
type ActivityItem = Awaited<ReturnType<typeof getActivityLog>>["items"][number];
// Human phrasing for the stored action codes.
const ACTION_LABELS: Record<string, string> = {
"project.create": "created a project",
"project.archive": "archived a project",
"project.restore": "restored a project",
"project.domain_set": "set a project domain",
"audit.start": "started a site audit",
"team.user_created": "added a user",
"team.user_removed": "removed a user",
"team.password_reset": "reset a password",
"team.invitation_sent": "sent an invitation",
};
const ACTION_OPTIONS = Object.entries(ACTION_LABELS);
function actionLabel(action: string) {
return ACTION_LABELS[action] ?? action;
}
function describeTarget(item: ActivityItem) {
return item.targetLabel ?? item.targetId ?? "";
}
// Owner/admin view of who did what in the workspace.
export function ActivityLogView() {
const [actorUserId, setActorUserId] = useState("");
const [action, setAction] = useState("");
const actorsQuery = useQuery({
queryKey: ["activity-actors"],
queryFn: () => getActivityActors(),
});
const logQuery = useInfiniteQuery({
queryKey: ["activity-log", actorUserId, action],
initialPageParam: undefined as string | undefined,
queryFn: ({ pageParam }) =>
getActivityLog({
data: {
actorUserId: actorUserId || undefined,
action: action || undefined,
before: pageParam,
limit: 50,
},
}),
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});
if (logQuery.isError) {
return (
<div className="space-y-3">
<p className="text-sm text-base-content/70">
We couldn&rsquo;t load the activity log.
</p>
<button
type="button"
className="btn btn-soft btn-sm"
onClick={() => void logQuery.refetch()}
>
Try again
</button>
</div>
);
}
const rows = logQuery.data?.pages.flatMap((page) => page.items) ?? [];
return (
<section className="space-y-3">
<h2 className="text-sm font-medium text-base-content/50">Activity</h2>
<div className="flex flex-wrap gap-2">
<select
className="select select-bordered select-sm"
value={actorUserId}
onChange={(event) => setActorUserId(event.target.value)}
aria-label="Filter by user"
>
<option value="">All users</option>
{(actorsQuery.data ?? []).map((actor) => (
<option key={actor.actorUserId} value={actor.actorUserId}>
{actor.actorEmail}
</option>
))}
</select>
<select
className="select select-bordered select-sm"
value={action}
onChange={(event) => setAction(event.target.value)}
aria-label="Filter by action"
>
<option value="">All actions</option>
{ACTION_OPTIONS.map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</div>
{logQuery.isPending ? (
<div className="flex justify-center py-6">
<span className="loading loading-spinner loading-md" />
</div>
) : rows.length === 0 ? (
<p className="py-6 text-center text-sm text-base-content/60">
No activity recorded yet.
</p>
) : (
<div className="overflow-x-auto rounded-lg border border-base-300">
<table className="table table-sm">
<thead>
<tr>
<th>When</th>
<th>User</th>
<th>Action</th>
<th>Target</th>
</tr>
</thead>
<tbody>
{rows.map((item) => (
<tr key={item.id}>
<td className="whitespace-nowrap text-xs text-base-content/60">
{new Date(item.createdAt).toLocaleString()}
</td>
<td className="text-xs">{item.actorEmail}</td>
<td>{actionLabel(item.action)}</td>
<td className="text-xs text-base-content/70">
{describeTarget(item)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{logQuery.hasNextPage ? (
<div className="flex justify-center">
<button
type="button"
className="btn btn-soft btn-sm"
disabled={logQuery.isFetchingNextPage}
onClick={() => void logQuery.fetchNextPage()}
>
{logQuery.isFetchingNextPage ? "Loading…" : "Load more"}
</button>
</div>
) : null}
</section>
);
}

35
src/db/activity.schema.ts Normal file
View File

@ -0,0 +1,35 @@
import { sql } from "drizzle-orm";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// Append-only record of who did what in a workspace. Columns are plain text (no
// FKs): the log must outlive the rows it references — a deleted project or a
// removed teammate should still show in history — so target_label carries a
// human-readable snapshot taken at write time.
export const activityLog = sqliteTable(
"activity_log",
{
id: text("id").primaryKey(),
organizationId: text("organization_id").notNull(),
actorUserId: text("actor_user_id").notNull(),
actorEmail: text("actor_email").notNull(),
action: text("action").notNull(),
targetType: text("target_type"),
targetId: text("target_id"),
targetLabel: text("target_label"),
metadata: text("metadata"),
ipAddress: text("ip_address"),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
},
(table) => [
index("activity_log_org_created_at_idx").on(
table.organizationId,
table.createdAt,
),
index("activity_log_org_actor_idx").on(
table.organizationId,
table.actorUserId,
),
],
);

View File

@ -2,6 +2,7 @@
// which is the provider-aware barrel) so the D1 client always binds to the
// SQLite tables regardless of DATABASE_PROVIDER.
export * from "../app.schema";
export * from "../activity.schema";
export * from "../project-context.schema";
export * from "../audit.schema";
export * from "../sam.schema";

View File

@ -0,0 +1,32 @@
import { index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
// Postgres mirror of ../activity.schema.ts — kept structurally identical
// (schema-parity.test.ts enforces this).
export const activityLog = pgTable(
"activity_log",
{
id: text("id").primaryKey(),
organizationId: text("organization_id").notNull(),
actorUserId: text("actor_user_id").notNull(),
actorEmail: text("actor_email").notNull(),
action: text("action").notNull(),
targetType: text("target_type"),
targetId: text("target_id"),
targetLabel: text("target_label"),
metadata: text("metadata"),
ipAddress: text("ip_address"),
createdAt: timestamp("created_at", { mode: "date", withTimezone: true })
.defaultNow()
.notNull(),
},
(table) => [
index("activity_log_org_created_at_idx").on(
table.organizationId,
table.createdAt,
),
index("activity_log_org_actor_idx").on(
table.organizationId,
table.actorUserId,
),
],
);

View File

@ -1,4 +1,5 @@
export * from "./app.schema";
export * from "./activity.schema";
export * from "./project-context.schema";
export * from "./audit.schema";
export * from "./sam.schema";

View File

@ -5,6 +5,7 @@ import { getTableConfig as getSqliteTableConfig } from "drizzle-orm/sqlite-core"
import { getTableConfig as getPgTableConfig } from "drizzle-orm/pg-core";
import { sort } from "remeda";
import { describe, expect, it } from "vitest";
import * as sqliteActivity from "./activity.schema";
import * as sqliteApp from "./app.schema";
import * as sqliteProjectContext from "./project-context.schema";
import * as sqliteAudit from "./audit.schema";
@ -14,6 +15,7 @@ import * as sqliteBilling from "./billing.schema";
import * as sqliteGa4 from "./ga4.schema";
import * as sqliteGsc from "./gsc.schema";
import * as sqliteTelemetry from "./telemetry.schema";
import * as pgActivity from "./pg/activity.schema";
import * as pgApp from "./pg/app.schema";
import * as pgProjectContext from "./pg/project-context.schema";
import * as pgAudit from "./pg/audit.schema";
@ -146,6 +148,7 @@ function checkNames(table: Table, dialect: Dialect): string[] {
const sqliteAppTables = tablesFrom(
sqliteApp,
sqliteActivity,
sqliteProjectContext,
sqliteAudit,
sqliteSam,
@ -156,6 +159,7 @@ const sqliteAppTables = tablesFrom(
);
const pgAppTables = tablesFrom(
pgApp,
pgActivity,
pgProjectContext,
pgAudit,
pgSam,

View File

@ -1,4 +1,5 @@
import { getDatabaseProvider } from "./provider";
import * as sqliteActivity from "./activity.schema";
import * as sqliteApp from "./app.schema";
import * as sqliteProjectContext from "./project-context.schema";
import * as sqliteAudit from "./audit.schema";
@ -8,6 +9,7 @@ import * as sqliteBilling from "./billing.schema";
import * as sqliteGa4 from "./ga4.schema";
import * as sqliteGsc from "./gsc.schema";
import * as sqliteTelemetry from "./telemetry.schema";
import * as pgActivity from "./pg/activity.schema";
import * as pgApp from "./pg/app.schema";
import * as pgProjectContext from "./pg/project-context.schema";
import * as pgAudit from "./pg/audit.schema";
@ -29,6 +31,7 @@ import * as pgTelemetry from "./pg/telemetry.schema";
// schema is the one structural artifact NOT regenerated by `db:generate`, so the
// parity test is its drift guard.
type AppSchema = typeof sqliteApp &
typeof sqliteActivity &
typeof sqliteProjectContext &
typeof sqliteAudit &
typeof sqliteSam &
@ -42,6 +45,7 @@ const runtimeSchema =
getDatabaseProvider() === "postgres"
? {
...pgApp,
...pgActivity,
...pgProjectContext,
...pgAudit,
...pgSam,
@ -53,6 +57,7 @@ const runtimeSchema =
}
: {
...sqliteApp,
...sqliteActivity,
...sqliteProjectContext,
...sqliteAudit,
...sqliteSam,
@ -67,6 +72,7 @@ const runtimeSchema =
const schema = runtimeSchema as unknown as AppSchema;
export const {
activityLog,
userOnboardingAnswers,
projects,
savedKeywords,

View File

@ -40,6 +40,7 @@ import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$'
import { Route as AuthenticatedOnboardingChatRouteImport } from './routes/_authenticated.onboarding.chat'
import { Route as AppSettingsUsersRouteImport } from './routes/_app/settings/users'
import { Route as AppSettingsOrganizationRouteImport } from './routes/_app/settings/organization'
import { Route as AppSettingsActivityRouteImport } from './routes/_app/settings/activity'
import { Route as AppHelpOpenrouterApiKeyRouteImport } from './routes/_app/help/openrouter-api-key'
import { Route as AppHelpDataforseoApiKeyRouteImport } from './routes/_app/help/dataforseo-api-key'
import { Route as ProjectPProjectIdRouteRouteImport } from './routes/_project/p/$projectId/route'
@ -220,6 +221,11 @@ const AppSettingsOrganizationRoute = AppSettingsOrganizationRouteImport.update({
path: '/organization',
getParentRoute: () => AppSettingsRoute,
} as any)
const AppSettingsActivityRoute = AppSettingsActivityRouteImport.update({
id: '/activity',
path: '/activity',
getParentRoute: () => AppSettingsRoute,
} as any)
const AppHelpOpenrouterApiKeyRoute = AppHelpOpenrouterApiKeyRouteImport.update({
id: '/help/openrouter-api-key',
path: '/help/openrouter-api-key',
@ -379,6 +385,7 @@ export interface FileRoutesByFullPath {
'/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/settings/activity': typeof AppSettingsActivityRoute
'/settings/organization': typeof AppSettingsOrganizationRoute
'/settings/users': typeof AppSettingsUsersRoute
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
@ -430,6 +437,7 @@ export interface FileRoutesByTo {
'/api/team-setup': typeof ApiTeamSetupRoute
'/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/settings/activity': typeof AppSettingsActivityRoute
'/settings/organization': typeof AppSettingsOrganizationRoute
'/settings/users': typeof AppSettingsUsersRoute
'/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
@ -485,6 +493,7 @@ export interface FileRoutesById {
'/_project/p/$projectId': typeof ProjectPProjectIdRouteRouteWithChildren
'/_app/help/dataforseo-api-key': typeof AppHelpDataforseoApiKeyRoute
'/_app/help/openrouter-api-key': typeof AppHelpOpenrouterApiKeyRoute
'/_app/settings/activity': typeof AppSettingsActivityRoute
'/_app/settings/organization': typeof AppSettingsOrganizationRoute
'/_app/settings/users': typeof AppSettingsUsersRoute
'/_authenticated/onboarding/chat': typeof AuthenticatedOnboardingChatRoute
@ -540,6 +549,7 @@ export interface FileRouteTypes {
| '/p/$projectId'
| '/help/dataforseo-api-key'
| '/help/openrouter-api-key'
| '/settings/activity'
| '/settings/organization'
| '/settings/users'
| '/onboarding/chat'
@ -591,6 +601,7 @@ export interface FileRouteTypes {
| '/api/team-setup'
| '/help/dataforseo-api-key'
| '/help/openrouter-api-key'
| '/settings/activity'
| '/settings/organization'
| '/settings/users'
| '/onboarding/chat'
@ -645,6 +656,7 @@ export interface FileRouteTypes {
| '/_project/p/$projectId'
| '/_app/help/dataforseo-api-key'
| '/_app/help/openrouter-api-key'
| '/_app/settings/activity'
| '/_app/settings/organization'
| '/_app/settings/users'
| '/_authenticated/onboarding/chat'
@ -913,6 +925,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppSettingsOrganizationRouteImport
parentRoute: typeof AppSettingsRoute
}
'/_app/settings/activity': {
id: '/_app/settings/activity'
path: '/activity'
fullPath: '/settings/activity'
preLoaderRoute: typeof AppSettingsActivityRouteImport
parentRoute: typeof AppSettingsRoute
}
'/_app/help/openrouter-api-key': {
id: '/_app/help/openrouter-api-key'
path: '/help/openrouter-api-key'
@ -1085,12 +1104,14 @@ declare module '@tanstack/react-router' {
}
interface AppSettingsRouteChildren {
AppSettingsActivityRoute: typeof AppSettingsActivityRoute
AppSettingsOrganizationRoute: typeof AppSettingsOrganizationRoute
AppSettingsUsersRoute: typeof AppSettingsUsersRoute
AppSettingsIndexRoute: typeof AppSettingsIndexRoute
}
const AppSettingsRouteChildren: AppSettingsRouteChildren = {
AppSettingsActivityRoute: AppSettingsActivityRoute,
AppSettingsOrganizationRoute: AppSettingsOrganizationRoute,
AppSettingsUsersRoute: AppSettingsUsersRoute,
AppSettingsIndexRoute: AppSettingsIndexRoute,

View File

@ -19,6 +19,9 @@ function SettingsLayout() {
...(isTeamClientAuthMode()
? [{ to: "/settings/users" as const, label: "Users" }]
: []),
...(isSessionClientAuthMode()
? [{ to: "/settings/activity" as const, label: "Activity" }]
: []),
];
return (

View File

@ -0,0 +1,13 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { ActivityLogView } from "@/client/features/activity/ActivityLogView";
import { isSessionClientAuthMode } from "@/lib/auth-mode";
export const Route = createFileRoute("/_app/settings/activity")({
// Needs org membership + roles; the delegated modes have neither.
beforeLoad: () => {
if (!isSessionClientAuthMode()) {
throw notFound();
}
},
component: ActivityLogView,
});

View File

@ -0,0 +1,153 @@
import { createClient, type Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import type * as ActivityRepositoryModule from "./ActivityRepository";
// Real in-memory SQLite: list() is a filtered, ordered, keyset-paginated query
// — the part a mocked builder chain can't verify.
vi.mock("cloudflare:workers", () => ({ env: { DATABASE_PROVIDER: "d1" } }));
let client: Client;
let ActivityRepository: typeof ActivityRepositoryModule.ActivityRepository;
const ctx = (over: Partial<{ userId: string; userEmail: string }> = {}) => ({
userId: over.userId ?? "u1",
userEmail: over.userEmail ?? "a@example.com",
organizationId: "org1",
});
beforeAll(async () => {
client = createClient({ url: "file::memory:" });
const testDb = drizzle(client);
vi.doMock("@/db", () => ({ db: testDb }));
vi.doMock("@/db/d1/client", () => ({ d1Db: testDb }));
vi.doMock("@/db/pg/client", () => ({ pgDb: null }));
await client.executeMultiple(`
CREATE TABLE activity_log (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL,
actor_user_id TEXT NOT NULL,
actor_email TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT,
target_id TEXT,
target_label TEXT,
metadata TEXT,
ip_address TEXT,
created_at INTEGER NOT NULL
);
`);
({ ActivityRepository } = await import("./ActivityRepository"));
});
afterAll(() => {
client.close();
});
beforeEach(async () => {
await client.execute("DELETE FROM activity_log");
});
describe("ActivityRepository", () => {
it("records an entry with the actor and a serialized metadata blob", async () => {
await ActivityRepository.record({
context: ctx(),
action: "project.create",
targetType: "project",
targetId: "p1",
targetLabel: "Acme",
metadata: { domain: "acme.com" },
});
const [row] = await ActivityRepository.list({
organizationId: "org1",
limit: 10,
});
expect(row.actorEmail).toBe("a@example.com");
expect(row.action).toBe("project.create");
expect(row.targetLabel).toBe("Acme");
expect(JSON.parse(row.metadata ?? "{}")).toEqual({ domain: "acme.com" });
});
it("scopes to the organization and filters by actor and action", async () => {
await ActivityRepository.record({
context: ctx(),
action: "project.create",
});
await ActivityRepository.record({
context: ctx({ userId: "u2", userEmail: "b@example.com" }),
action: "audit.start",
});
await client.execute(
`INSERT INTO activity_log (id, organization_id, actor_user_id, actor_email, action, created_at) VALUES ('x', 'other-org', 'u1', 'a@example.com', 'project.create', 999)`,
);
expect(
await ActivityRepository.list({ organizationId: "org1", limit: 10 }),
).toHaveLength(2);
expect(
await ActivityRepository.list({
organizationId: "org1",
actorUserId: "u2",
limit: 10,
}),
).toHaveLength(1);
expect(
await ActivityRepository.list({
organizationId: "org1",
action: "audit.start",
limit: 10,
}),
).toHaveLength(1);
});
it("returns newest first and pages with the before cursor", async () => {
for (let i = 0; i < 5; i++) {
await client.execute(
`INSERT INTO activity_log (id, organization_id, actor_user_id, actor_email, action, created_at) VALUES ('e${i}', 'org1', 'u1', 'a@example.com', 'project.create', ${1000 + i})`,
);
}
const firstPage = await ActivityRepository.list({
organizationId: "org1",
limit: 2,
});
expect(firstPage.map((r) => r.id)).toEqual(["e4", "e3"]);
const secondPage = await ActivityRepository.list({
organizationId: "org1",
limit: 2,
before: firstPage[firstPage.length - 1].createdAt,
});
expect(secondPage.map((r) => r.id)).toEqual(["e2", "e1"]);
});
it("lists distinct actors for the filter dropdown", async () => {
await ActivityRepository.record({
context: ctx(),
action: "project.create",
});
await ActivityRepository.record({ context: ctx(), action: "audit.start" });
await ActivityRepository.record({
context: ctx({ userId: "u2", userEmail: "b@example.com" }),
action: "project.create",
});
const actors = await ActivityRepository.listActors("org1");
expect(actors).toEqual([
{ actorUserId: "u1", actorEmail: "a@example.com" },
{ actorUserId: "u2", actorEmail: "b@example.com" },
]);
});
});

View File

@ -0,0 +1,100 @@
import { randomUUID } from "node:crypto";
import { and, desc, eq, lt } from "drizzle-orm";
import { db } from "@/db";
import { activityLog } from "@/db/schema";
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
// The set of things worth recording. Keep it small and stable — this is a
// human-readable audit trail, not analytics.
export const ACTIVITY_ACTIONS = [
"project.create",
"project.archive",
"project.restore",
"project.domain_set",
"audit.start",
"team.user_created",
"team.user_removed",
"team.password_reset",
"team.invitation_sent",
] as const;
export type ActivityAction = (typeof ACTIVITY_ACTIONS)[number];
type ActorContext = Pick<
EnsuredUserContext,
"userId" | "userEmail" | "organizationId"
>;
type RecordInput = {
context: ActorContext;
action: ActivityAction;
targetType?: string;
targetId?: string;
// Human-readable snapshot (project name, teammate email) taken now, so the
// log stays meaningful after the target is deleted.
targetLabel?: string;
metadata?: Record<string, string | number | boolean | null>;
};
async function record(input: RecordInput): Promise<void> {
try {
await db.insert(activityLog).values({
id: randomUUID(),
organizationId: input.context.organizationId,
actorUserId: input.context.userId,
actorEmail: input.context.userEmail,
action: input.action,
targetType: input.targetType ?? null,
targetId: input.targetId ?? null,
targetLabel: input.targetLabel ?? null,
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
ipAddress: null,
createdAt: new Date(),
});
} catch (error) {
// Logging must never break the action it records.
console.error("[activity] write failed", { action: input.action, error });
}
}
type ListInput = {
organizationId: string;
actorUserId?: string;
action?: string;
before?: Date;
limit: number;
};
async function list(input: ListInput) {
const conditions = [eq(activityLog.organizationId, input.organizationId)];
if (input.actorUserId) {
conditions.push(eq(activityLog.actorUserId, input.actorUserId));
}
if (input.action) {
conditions.push(eq(activityLog.action, input.action));
}
if (input.before) {
conditions.push(lt(activityLog.createdAt, input.before));
}
return db
.select()
.from(activityLog)
.where(and(...conditions))
.orderBy(desc(activityLog.createdAt))
.limit(input.limit);
}
// Distinct actors seen in this org's log, for the filter dropdown.
async function listActors(organizationId: string) {
return db
.selectDistinct({
actorUserId: activityLog.actorUserId,
actorEmail: activityLog.actorEmail,
})
.from(activityLog)
.where(eq(activityLog.organizationId, organizationId))
.orderBy(activityLog.actorEmail);
}
export const ActivityRepository = { record, list, listActors } as const;

View File

@ -0,0 +1,60 @@
import { createServerFn } from "@tanstack/react-start";
import { z } from "zod";
import { requireOrgPermission } from "@/server/auth/org-gate";
import { ActivityRepository } from "@/server/features/activity/ActivityRepository";
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
const listSchema = z.object({
actorUserId: z.string().optional(),
action: z.string().optional(),
before: z.string().optional(),
limit: z.number().int().min(1).max(100).optional(),
});
function toIso(value: Date | number): string {
return value instanceof Date
? value.toISOString()
: new Date(value).toISOString();
}
export const getActivityLog = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.validator(listSchema)
.handler(async ({ data, context }) => {
// Owner/admin only — same gate as team-user management.
requireOrgPermission(context, { member: ["create"] });
const limit = data.limit ?? 50;
const rows = await ActivityRepository.list({
organizationId: context.organizationId,
actorUserId: data.actorUserId,
action: data.action,
before: data.before ? new Date(data.before) : undefined,
limit: limit + 1,
});
const hasMore = rows.length > limit;
const items = (hasMore ? rows.slice(0, limit) : rows).map((row) => ({
id: row.id,
actorUserId: row.actorUserId,
actorEmail: row.actorEmail,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
targetLabel: row.targetLabel,
metadata: row.metadata,
createdAt: toIso(row.createdAt),
}));
return {
items,
nextCursor: hasMore ? (items[items.length - 1]?.createdAt ?? null) : null,
};
});
export const getActivityActors = createServerFn({ method: "POST" })
.middleware(requireAuthenticatedContext)
.handler(async ({ context }) => {
requireOrgPermission(context, { member: ["create"] });
return ActivityRepository.listActors(context.organizationId);
});

View File

@ -1,6 +1,7 @@
import { createServerFn } from "@tanstack/react-start";
import { waitUntil } from "cloudflare:workers";
import { requireOrgPermission } from "@/server/auth/org-gate";
import { ActivityRepository } from "@/server/features/activity/ActivityRepository";
import { AuditService } from "@/server/features/audit/services/AuditService";
import { captureServerEvent } from "@/server/lib/posthog";
import { requireProjectContext } from "@/serverFunctions/middleware";
@ -29,6 +30,15 @@ export const startAudit = createServerFn({ method: "POST" })
limitTier,
});
await ActivityRepository.record({
context,
action: "audit.start",
targetType: "project",
targetId: context.projectId,
targetLabel: data.startUrl,
metadata: { maxPages: data.maxPages ?? 50 },
});
waitUntil(
captureServerEvent({
distinctId: context.userId,

View File

@ -5,6 +5,7 @@ import { getAuth, getHostedBaseUrl } from "@/lib/auth";
import { hasOrgPermission } from "@/lib/org-permissions";
import { consumeInvitationSendBudget } from "@/server/auth/invitation-send-limit";
import { requireOrgPermission } from "@/server/auth/org-gate";
import { ActivityRepository } from "@/server/features/activity/ActivityRepository";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { sendHostedInvitationEmail } from "@/server/email/loops";
import { AppError } from "@/server/lib/errors";
@ -128,6 +129,14 @@ export const sendTeamInvitation = createServerFn({ method: "POST" })
},
});
await ActivityRepository.record({
context,
action: "team.invitation_sent",
targetType: "invitation",
targetId: invitation.id,
targetLabel: data.email,
});
const [inviter, memberships] = await Promise.all([
AuthRepository.getHostedUser(context.userId),
AuthRepository.listMembershipsForUser(context.userId),

View File

@ -1,5 +1,6 @@
import { createServerFn } from "@tanstack/react-start";
import { requireOrgPermission } from "@/server/auth/org-gate";
import { ActivityRepository } from "@/server/features/activity/ActivityRepository";
import { ProjectService } from "@/server/features/projects/services/ProjectService";
import {
requireAuthenticatedContext,
@ -28,7 +29,18 @@ export const createProject = createServerFn({ method: "POST" })
.validator(createProjectSchema)
.handler(async ({ data, context }) => {
requireOrgPermission(context, { project: ["create"] });
return ProjectService.createProject(context.organizationId, data);
const project = await ProjectService.createProject(
context.organizationId,
data,
);
await ActivityRepository.record({
context,
action: "project.create",
targetType: "project",
targetId: project.id,
targetLabel: project.name,
});
return project;
});
export const updateProject = createServerFn({ method: "POST" })
@ -41,9 +53,20 @@ export const updateProject = createServerFn({ method: "POST" })
export const setProjectDomain = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.validator(setProjectDomainSchema)
.handler(async ({ data, context }) =>
ProjectService.setProjectDomain(context.organizationId, data),
.handler(async ({ data, context }) => {
const result = await ProjectService.setProjectDomain(
context.organizationId,
data,
);
await ActivityRepository.record({
context,
action: "project.domain_set",
targetType: "project",
targetId: data.projectId,
targetLabel: data.domain,
});
return result;
});
export const setProjectMarket = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
@ -57,7 +80,17 @@ export const archiveProject = createServerFn({ method: "POST" })
.validator(archiveProjectSchema)
.handler(async ({ data, context }) => {
requireOrgPermission(context, { project: ["delete"] });
return ProjectService.archiveProject(context.organizationId, data);
const result = await ProjectService.archiveProject(
context.organizationId,
data,
);
await ActivityRepository.record({
context,
action: "project.archive",
targetType: "project",
targetId: data.projectId,
});
return result;
});
export const getArchivedProjects = createServerFn({ method: "POST" })
@ -71,7 +104,17 @@ export const restoreProject = createServerFn({ method: "POST" })
.validator(restoreProjectSchema)
.handler(async ({ data, context }) => {
requireOrgPermission(context, { project: ["delete"] });
return ProjectService.restoreProject(context.organizationId, data);
const result = await ProjectService.restoreProject(
context.organizationId,
data,
);
await ActivityRepository.record({
context,
action: "project.restore",
targetType: "project",
targetId: data.archivedProjectId,
});
return result;
});
export const getProjectAccess = createServerFn({ method: "POST" })

View File

@ -6,6 +6,7 @@ import {
HOSTED_PASSWORD_MAX_LENGTH,
HOSTED_PASSWORD_MIN_LENGTH,
} from "@/lib/auth-options";
import { ActivityRepository } from "@/server/features/activity/ActivityRepository";
import { AuthRepository } from "@/server/auth/repositories/AuthRepository";
import { requireOrgPermission } from "@/server/auth/org-gate";
import { AppError } from "@/server/lib/errors";
@ -89,6 +90,14 @@ export const createTeamUser = createServerFn({ method: "POST" })
role: data.role,
organizationId: context.organizationId,
});
await ActivityRepository.record({
context,
action: "team.user_created",
targetType: "user",
targetId: created.userId,
targetLabel: created.email,
metadata: { role: data.role },
});
return { userId: created.userId, email: created.email };
} catch (error) {
throw new AppError(
@ -117,6 +126,12 @@ export const resetTeamUserPassword = createServerFn({ method: "POST" })
await setCredentialPassword(data.userId, data.password);
await revokeUserSessions(data.userId);
await ActivityRepository.record({
context,
action: "team.password_reset",
targetType: "user",
targetId: data.userId,
});
return { ok: true };
});
@ -147,5 +162,11 @@ export const removeTeamUser = createServerFn({ method: "POST" })
// can re-add them later with a fresh password.
await AuthRepository.removeMembership(data.userId, context.organizationId);
await revokeUserSessions(data.userId);
await ActivityRepository.record({
context,
action: "team.user_removed",
targetType: "user",
targetId: data.userId,
});
return { ok: true };
});