Phase 4: activity log — who did what in the workspace
- 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:
parent
29bda614a4
commit
ce75d45141
@ -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
|
||||
|
||||
16
drizzle-pg/0023_clever_prowler.sql
Normal file
16
drizzle-pg/0023_clever_prowler.sql
Normal 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");
|
||||
4243
drizzle-pg/meta/0023_snapshot.json
Normal file
4243
drizzle-pg/meta/0023_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -162,6 +162,13 @@
|
||||
"when": 1787773500453,
|
||||
"tag": "0022_third_supernaut",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 23,
|
||||
"version": "7",
|
||||
"when": 1788983569510,
|
||||
"tag": "0023_clever_prowler",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
16
drizzle/0045_noisy_ghost_rider.sql
Normal file
16
drizzle/0045_noisy_ghost_rider.sql
Normal 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`);
|
||||
3852
drizzle/meta/0045_snapshot.json
Normal file
3852
drizzle/meta/0045_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
158
src/client/features/activity/ActivityLogView.tsx
Normal file
158
src/client/features/activity/ActivityLogView.tsx
Normal 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’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
35
src/db/activity.schema.ts
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -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";
|
||||
|
||||
32
src/db/pg/activity.schema.ts
Normal file
32
src/db/pg/activity.schema.ts
Normal 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,
|
||||
),
|
||||
],
|
||||
);
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -19,6 +19,9 @@ function SettingsLayout() {
|
||||
...(isTeamClientAuthMode()
|
||||
? [{ to: "/settings/users" as const, label: "Users" }]
|
||||
: []),
|
||||
...(isSessionClientAuthMode()
|
||||
? [{ to: "/settings/activity" as const, label: "Activity" }]
|
||||
: []),
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
13
src/routes/_app/settings/activity.tsx
Normal file
13
src/routes/_app/settings/activity.tsx
Normal 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,
|
||||
});
|
||||
153
src/server/features/activity/ActivityRepository.test.ts
Normal file
153
src/server/features/activity/ActivityRepository.test.ts
Normal 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" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
100
src/server/features/activity/ActivityRepository.ts
Normal file
100
src/server/features/activity/ActivityRepository.ts
Normal 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;
|
||||
60
src/serverFunctions/activity.ts
Normal file
60
src/serverFunctions/activity.ts
Normal 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);
|
||||
});
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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" })
|
||||
|
||||
@ -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 };
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user