Files
zpan/server/services/activity.ts
T
Jasper VanandCopilot 7d81ef2b72 feat: implement admin audit logs (#355)
- Add listAdminAuditEvents service with pagination and filters (orgId, userId, action, targetType)
- Add GET /api/admin/audit route protected by requireAdmin + requireFeature('audit_log')
- Mount admin audit route in app.ts and export AdminAuditRoute type
- Add AdminAuditEvent shared type with orgName field
- Add listAdminAuditQuerySchema to shared schemas
- Add adminAuditApi RPC client and listAdminAuditLogs API wrapper
- Replace placeholder /admin/audit page with real table/list UI using React Query
- Show Pro upgrade prompt when audit_log entitlement is unavailable
- Add empty, loading, error, and load-more states to UI
- Remove comingSoon from audit_log in feature registry
- Update en.json and zh.json with real audit log labels
- Add integration tests covering 401/403/402 guards, filters, pagination, and response shape
- Add unit tests for listAdminAuditLogs API wrapper


Agent-Profile: https://agent-kanban.dev/agents/f68cfbce6456edb5

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-04 07:50:02 -04:00

158 lines
4.8 KiB
TypeScript

import { and, count, desc, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { organization, user } from '../db/auth-schema'
import { activityEvents } from '../db/schema'
import type { Database } from '../platform/interface'
export type ActivityEventRow = typeof activityEvents.$inferSelect
export interface ActivityEventWithUser extends ActivityEventRow {
user: { id: string; name: string; image: string | null }
}
interface RecordActivityInput {
orgId: string
userId: string
action: string
targetType: string
targetId?: string
targetName: string
metadata?: Record<string, unknown>
}
export async function recordActivity(db: Database, event: RecordActivityInput): Promise<void> {
await db.insert(activityEvents).values({
id: nanoid(),
orgId: event.orgId,
userId: event.userId,
action: event.action,
targetType: event.targetType,
targetId: event.targetId ?? null,
targetName: event.targetName,
metadata: event.metadata ? JSON.stringify(event.metadata) : null,
createdAt: new Date(),
})
}
export async function listActivities(
db: Database,
orgId: string,
opts: { page?: number; pageSize?: number },
): Promise<{ items: ActivityEventWithUser[]; total: number }> {
const page = opts.page ?? 1
const pageSize = opts.pageSize ?? 20
const offset = (page - 1) * pageSize
const countRows = await db.select({ count: count() }).from(activityEvents).where(eq(activityEvents.orgId, orgId))
const total = countRows[0]?.count ?? 0
const rows = await db
.select({
id: activityEvents.id,
orgId: activityEvents.orgId,
userId: activityEvents.userId,
action: activityEvents.action,
targetType: activityEvents.targetType,
targetId: activityEvents.targetId,
targetName: activityEvents.targetName,
metadata: activityEvents.metadata,
createdAt: activityEvents.createdAt,
userName: user.name,
userImage: user.image,
})
.from(activityEvents)
.leftJoin(user, eq(activityEvents.userId, user.id))
.where(eq(activityEvents.orgId, orgId))
.orderBy(desc(activityEvents.createdAt))
.limit(pageSize)
.offset(offset)
const items = rows.map((row) => ({
id: row.id,
orgId: row.orgId,
userId: row.userId,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
targetName: row.targetName,
metadata: row.metadata,
createdAt: row.createdAt,
user: { id: row.userId, name: row.userName ?? '', image: row.userImage ?? null },
}))
return { items, total }
}
export interface AdminAuditEventWithOrg extends ActivityEventWithUser {
orgName: string | null
}
interface ListAdminAuditOpts {
page?: number
pageSize?: number
orgId?: string
userId?: string
action?: string
targetType?: string
}
export async function listAdminAuditEvents(
db: Database,
opts: ListAdminAuditOpts,
): Promise<{ items: AdminAuditEventWithOrg[]; total: number; page: number; pageSize: number }> {
const page = opts.page ?? 1
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 20))
const offset = (page - 1) * pageSize
const filters = [
opts.orgId ? eq(activityEvents.orgId, opts.orgId) : undefined,
opts.userId ? eq(activityEvents.userId, opts.userId) : undefined,
opts.action ? eq(activityEvents.action, opts.action) : undefined,
opts.targetType ? eq(activityEvents.targetType, opts.targetType) : undefined,
].filter(Boolean) as Parameters<typeof and>
const whereClause = filters.length > 0 ? and(...filters) : undefined
const countRows = await db.select({ count: count() }).from(activityEvents).where(whereClause)
const total = countRows[0]?.count ?? 0
const rows = await db
.select({
id: activityEvents.id,
orgId: activityEvents.orgId,
userId: activityEvents.userId,
action: activityEvents.action,
targetType: activityEvents.targetType,
targetId: activityEvents.targetId,
targetName: activityEvents.targetName,
metadata: activityEvents.metadata,
createdAt: activityEvents.createdAt,
userName: user.name,
userImage: user.image,
orgName: organization.name,
})
.from(activityEvents)
.leftJoin(user, eq(activityEvents.userId, user.id))
.leftJoin(organization, eq(activityEvents.orgId, organization.id))
.where(whereClause)
.orderBy(desc(activityEvents.createdAt))
.limit(pageSize)
.offset(offset)
const items = rows.map((row) => ({
id: row.id,
orgId: row.orgId,
userId: row.userId,
action: row.action,
targetType: row.targetType,
targetId: row.targetId,
targetName: row.targetName,
metadata: row.metadata,
createdAt: row.createdAt,
user: { id: row.userId, name: row.userName ?? '', image: row.userImage ?? null },
orgName: row.orgName ?? null,
}))
return { items, total, page, pageSize }
}