feat: add activity feed for team workspaces (#298)

Add activity_events table, migration, activity service, teams API route,
integration tests, and frontend activity timeline page. Tracks file
operations (upload, create, delete, rename, move, restore) per team.

- server/db/schema.ts: activityEvents table
- migrations/0007_activity_feed.sql + _journal.json: migration
- server/services/activity.ts: recordActivity + listActivities
- server/services/matter.ts: instrument with optional userId param
- server/routes/objects.ts: pass userId to matter service calls
- server/routes/teams.ts: GET /api/teams/:teamId/activity endpoint
- server/routes/teams.integration.test.ts: full integration test coverage
- server/test/setup.ts: add activity_events table to test schema
- shared/types/index.ts: ActivityEvent type
- src/lib/rpc.ts + api.ts: teamsApi client + listTeamActivities()
- src/routes/.../activity.tsx: timeline page with infinite scroll
- src/routeTree.gen.ts: register new route
- i18n: EN + ZH strings for activity feed

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

Co-authored-by: Bob <aibob@mails.agent-kanban.dev>
This commit is contained in:
Jasper Van
2026-04-14 13:22:54 -04:00
committed by GitHub
parent bfba5fa4e4
commit d5033150fc
17 changed files with 931 additions and 14 deletions
+13
View File
@@ -0,0 +1,13 @@
CREATE TABLE `activity_events` (
`id` text PRIMARY KEY NOT NULL,
`org_id` text NOT NULL,
`user_id` text NOT NULL,
`action` text NOT NULL,
`target_type` text NOT NULL,
`target_id` text,
`target_name` text NOT NULL,
`metadata` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `activity_events_org_id_idx` ON `activity_events` (`org_id`);
+7
View File
@@ -50,6 +50,13 @@
"when": 1775120000000,
"tag": "0006_public_profile",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"when": 1776000000000,
"tag": "0007_activity_feed",
"breakpoints": true
}
]
}
+3
View File
@@ -14,6 +14,7 @@ import profile from './routes/profile'
import { adminQuotas, userQuotas } from './routes/quotas'
import storages from './routes/storages'
import system from './routes/system'
import teams from './routes/teams'
import trash from './routes/trash'
import users from './routes/users'
@@ -47,6 +48,7 @@ export function createApp(platform: Platform, auth: Auth) {
// Each .route() call is independent — TypeScript doesn't stack types.
app.route('/api/objects', objects)
app.route('/api/recycle-bin', trash)
app.route('/api/teams', teams)
app.route('/api/admin/storages', storages)
app.route('/api/admin/users', users)
app.route('/api/admin/email-config', emailConfig)
@@ -77,3 +79,4 @@ export type AdminInviteCodesRoute = typeof adminInviteCodes
export type PublicInviteCodesRoute = typeof publicInviteCodes
export type AuthProvidersRoute = typeof authProviders
export type ProfileRoute = typeof profile
export type TeamsRoute = typeof teams
+12
View File
@@ -58,3 +58,15 @@ export const systemOptions = sqliteTable('system_options', {
value: text('value').notNull().default(''),
public: integer('public', { mode: 'boolean' }).default(false),
})
export const activityEvents = sqliteTable('activity_events', {
id: text('id').primaryKey(),
orgId: text('org_id').notNull(),
userId: text('user_id').notNull(),
action: text('action').notNull(), // 'upload', 'create', 'delete', 'rename', 'move', 'restore'
targetType: text('target_type').notNull(), // 'file', 'folder'
targetId: text('target_id'),
targetName: text('target_name').notNull(),
metadata: text('metadata'), // JSON
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
})
+9 -4
View File
@@ -77,6 +77,7 @@ const app = new Hono<Env>()
const matter = await createMatter(db, {
orgId,
userId,
name,
type: isFolder ? 'folder' : type,
size: isFolder ? 0 : size,
@@ -98,8 +99,9 @@ const app = new Hono<Env>()
const { ids, parent } = c.req.valid('json')
const db = c.get('platform').db
const userId = c.get('userId')!
try {
const moved = await batchMove(db, orgId, ids, parent)
const moved = await batchMove(db, orgId, ids, parent, userId)
return c.json({ moved: moved.length })
} catch (e) {
return c.json({ error: (e as Error).message }, 400)
@@ -180,7 +182,8 @@ const app = new Hono<Env>()
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const db = c.get('platform').db
const matter = await updateMatter(db, c.req.param('id'), orgId, c.req.valid('json'))
const userId = c.get('userId')!
const matter = await updateMatter(db, c.req.param('id'), orgId, c.req.valid('json'), userId)
if (!matter) return c.json({ error: 'Not found' }, 404)
return c.json(matter)
})
@@ -198,7 +201,8 @@ const app = new Hono<Env>()
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const db = c.get('platform').db
const matter = await trashMatter(db, orgId, c.req.param('id'))
const userId = c.get('userId')!
const matter = await trashMatter(db, orgId, c.req.param('id'), userId)
if (!matter) return c.json({ error: 'Not found' }, 404)
return c.json(matter)
})
@@ -206,7 +210,8 @@ const app = new Hono<Env>()
const orgId = c.get('orgId')
if (!orgId) return c.json({ error: 'No active organization' }, 400)
const db = c.get('platform').db
const matter = await restoreMatter(db, orgId, c.req.param('id'))
const userId = c.get('userId')!
const matter = await restoreMatter(db, orgId, c.req.param('id'), userId)
if (!matter) return c.json({ error: 'Not found' }, 404)
return c.json(matter)
})
+431
View File
@@ -0,0 +1,431 @@
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { authedHeaders, createTestApp } from '../test/setup.js'
type DbType = Awaited<ReturnType<typeof createTestApp>>['db']
type AppType = Awaited<ReturnType<typeof createTestApp>>['app']
async function getOrgId(db: DbType): Promise<string> {
const rows = await db.all<{ id: string }>(
sql`SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1`,
)
return rows[0].id
}
async function getUserId(db: DbType, email = 'test@example.com'): Promise<string> {
const rows = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = ${email}`)
return rows[0].id
}
async function insertActivityEvent(
db: DbType,
opts: {
id: string
orgId: string
userId: string
action?: string
targetType?: string
targetId?: string | null
targetName?: string
metadata?: string | null
createdAt?: number
},
) {
await db.run(sql`
INSERT INTO activity_events (id, org_id, user_id, action, target_type, target_id, target_name, metadata, created_at)
VALUES (
${opts.id},
${opts.orgId},
${opts.userId},
${opts.action ?? 'upload'},
${opts.targetType ?? 'file'},
${opts.targetId ?? null},
${opts.targetName ?? 'report.pdf'},
${opts.metadata ?? null},
${opts.createdAt ?? Date.now()}
)
`)
}
// ─── Auth guard ────────────────────────────────────────────────────────────────
describe('GET /api/teams/:teamId/activity — auth', () => {
it('returns 401 without auth', async () => {
const { app } = await createTestApp()
const res = await app.request('/api/teams/some-id/activity')
expect(res.status).toBe(401)
})
})
// ─── Access control ────────────────────────────────────────────────────────────
describe('GET /api/teams/:teamId/activity — access control', () => {
it('returns 403 when authed user is not a member of a non-personal org', async () => {
const { app, db } = await createTestApp()
// Sign up a user (their personal org is created automatically)
const headers1 = await authedHeaders(app, 'user1@example.com')
const userId1 = await getUserId(db, 'user1@example.com')
// Create a non-personal team org and add only user1 as a member
const now = Date.now()
await db.run(
sql`INSERT INTO organization (id, name, slug, metadata, created_at) VALUES ('team-org-1', 'Team One', 'team-one', '{"type":"team"}', ${now})`,
)
await db.run(
sql`INSERT INTO member (id, organization_id, user_id, role, created_at) VALUES ('mem-1', 'team-org-1', ${userId1}, 'owner', ${now})`,
)
// Sign up user2 (not a member of the team org) and try to access it
const headers2 = await authedHeaders(app, 'user2@example.com')
// Suppress unused variable warning
void headers1
const res = await app.request('/api/teams/team-org-1/activity', { headers: headers2 })
expect(res.status).toBe(403)
})
it('returns 200 when authed user is the owner of their personal org', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const res = await app.request(`/api/teams/${orgId}/activity`, { headers })
expect(res.status).toBe(200)
})
it('returns 200 when authed user accesses any personal org (personal orgs are public to auth users)', async () => {
const { app, db } = await createTestApp()
// Sign up user1
await authedHeaders(app, 'user1@example.com')
const userId1 = await getUserId(db, 'user1@example.com')
// Get user1's personal org
const rows = await db.all<{ id: string }>(
sql`SELECT id FROM organization WHERE slug = ${'personal-' + userId1} LIMIT 1`,
)
const orgId1 = rows[0].id
// Sign up user2 and access user1's personal org
const headers2 = await authedHeaders(app, 'user2@example.com')
const res = await app.request(`/api/teams/${orgId1}/activity`, { headers: headers2 })
expect(res.status).toBe(200)
})
it('returns 200 when authed user is a member of a non-personal team org', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const userId = await getUserId(db)
const now = Date.now()
await db.run(
sql`INSERT INTO organization (id, name, slug, metadata, created_at) VALUES ('team-org-2', 'My Team', 'my-team', '{"type":"team"}', ${now})`,
)
await db.run(
sql`INSERT INTO member (id, organization_id, user_id, role, created_at) VALUES ('mem-t2', 'team-org-2', ${userId}, 'member', ${now})`,
)
const res = await app.request('/api/teams/team-org-2/activity', { headers })
expect(res.status).toBe(200)
})
})
// ─── Happy path ────────────────────────────────────────────────────────────────
describe('GET /api/teams/:teamId/activity — happy path', () => {
it('returns empty items list when there are no activity events', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const res = await app.request(`/api/teams/${orgId}/activity`, { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as { items: unknown[]; total: number; page: number; pageSize: number }
expect(body.items).toEqual([])
expect(body.total).toBe(0)
})
it('returns activity items with user info when events exist', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const userId = await getUserId(db)
await insertActivityEvent(db, { id: 'evt-1', orgId, userId, targetName: 'document.pdf' })
const res = await app.request(`/api/teams/${orgId}/activity`, { headers })
expect(res.status).toBe(200)
const body = (await res.json()) as {
items: Array<{ id: string; targetName: string; user: { id: string; name: string; image: string | null } }>
total: number
}
expect(body.total).toBe(1)
expect(body.items).toHaveLength(1)
expect(body.items[0].id).toBe('evt-1')
expect(body.items[0].targetName).toBe('document.pdf')
expect(body.items[0].user).toMatchObject({ id: userId, name: 'Test User' })
})
it('includes all expected activity event fields in each item', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const userId = await getUserId(db)
await insertActivityEvent(db, {
id: 'evt-fields',
orgId,
userId,
action: 'delete',
targetType: 'folder',
targetId: 'folder-abc',
targetName: 'archive',
})
const res = await app.request(`/api/teams/${orgId}/activity`, { headers })
const body = (await res.json()) as {
items: Array<{
id: string
orgId: string
userId: string
action: string
targetType: string
targetId: string
targetName: string
}>
}
const item = body.items[0]
expect(item.orgId).toBe(orgId)
expect(item.userId).toBe(userId)
expect(item.action).toBe('delete')
expect(item.targetType).toBe('folder')
expect(item.targetId).toBe('folder-abc')
expect(item.targetName).toBe('archive')
})
it('returns user image as null when user has no image', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const userId = await getUserId(db)
await insertActivityEvent(db, { id: 'evt-img', orgId, userId })
const res = await app.request(`/api/teams/${orgId}/activity`, { headers })
const body = (await res.json()) as { items: Array<{ user: { image: string | null } }> }
expect(body.items[0].user.image).toBeNull()
})
})
// ─── Pagination ────────────────────────────────────────────────────────────────
describe('GET /api/teams/:teamId/activity — pagination', () => {
it('returns default page=1 and pageSize=20 in response', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const res = await app.request(`/api/teams/${orgId}/activity`, { headers })
const body = (await res.json()) as { page: number; pageSize: number }
expect(body.page).toBe(1)
expect(body.pageSize).toBe(20)
})
it('respects explicit page and pageSize query params', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const res = await app.request(`/api/teams/${orgId}/activity?page=2&pageSize=5`, { headers })
const body = (await res.json()) as { page: number; pageSize: number }
expect(body.page).toBe(2)
expect(body.pageSize).toBe(5)
})
it('returns correct total count regardless of page', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const userId = await getUserId(db)
const now = Date.now()
for (let i = 1; i <= 7; i++) {
await insertActivityEvent(db, { id: `evt-total-${i}`, orgId, userId, createdAt: now + i })
}
const res = await app.request(`/api/teams/${orgId}/activity?page=1&pageSize=3`, { headers })
const body = (await res.json()) as { items: unknown[]; total: number }
expect(body.total).toBe(7)
expect(body.items).toHaveLength(3)
})
it('returns correct items on second page', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const userId = await getUserId(db)
const now = Date.now()
for (let i = 1; i <= 5; i++) {
await insertActivityEvent(db, {
id: `evt-page-${i}`,
orgId,
userId,
targetName: `file-${i}.pdf`,
createdAt: now + i,
})
}
// Page 2 with pageSize 3 should yield 2 items (the oldest two)
const res = await app.request(`/api/teams/${orgId}/activity?page=2&pageSize=3`, { headers })
const body = (await res.json()) as { items: Array<{ id: string }>; total: number }
expect(body.total).toBe(5)
expect(body.items).toHaveLength(2)
})
it('returns empty items when page is beyond total results', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const userId = await getUserId(db)
await insertActivityEvent(db, { id: 'evt-single', orgId, userId })
const res = await app.request(`/api/teams/${orgId}/activity?page=99&pageSize=20`, { headers })
const body = (await res.json()) as { items: unknown[]; total: number }
expect(body.total).toBe(1)
expect(body.items).toHaveLength(0)
})
})
// ─── Ordering ─────────────────────────────────────────────────────────────────
describe('GET /api/teams/:teamId/activity — ordering', () => {
it('returns items ordered by newest first', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const userId = await getUserId(db)
const base = Date.now()
await insertActivityEvent(db, { id: 'evt-old', orgId, userId, targetName: 'old.pdf', createdAt: base })
await insertActivityEvent(db, { id: 'evt-new', orgId, userId, targetName: 'new.pdf', createdAt: base + 1000 })
const res = await app.request(`/api/teams/${orgId}/activity`, { headers })
const body = (await res.json()) as { items: Array<{ id: string }> }
expect(body.items[0].id).toBe('evt-new')
expect(body.items[1].id).toBe('evt-old')
})
})
// ─── Metadata ─────────────────────────────────────────────────────────────────
describe('GET /api/teams/:teamId/activity — metadata', () => {
it('returns metadata field as stored when metadata is present', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const userId = await getUserId(db)
await insertActivityEvent(db, {
id: 'evt-meta',
orgId,
userId,
metadata: JSON.stringify({ size: 1024, mime: 'application/pdf' }),
})
const res = await app.request(`/api/teams/${orgId}/activity`, { headers })
const body = (await res.json()) as { items: Array<{ metadata: string | null }> }
expect(body.items[0].metadata).toBe('{"size":1024,"mime":"application/pdf"}')
})
it('returns null metadata when no metadata was stored', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const userId = await getUserId(db)
await insertActivityEvent(db, { id: 'evt-nometa', orgId, userId, metadata: null })
const res = await app.request(`/api/teams/${orgId}/activity`, { headers })
const body = (await res.json()) as { items: Array<{ metadata: string | null }> }
expect(body.items[0].metadata).toBeNull()
})
})
// ─── Multiple events across orgs ──────────────────────────────────────────────
describe('GET /api/teams/:teamId/activity — isolation', () => {
it('only returns events for the requested org, not other orgs', async () => {
const { app, db } = await createTestApp()
const headers1 = await authedHeaders(app, 'user1@example.com')
const userId1 = await getUserId(db, 'user1@example.com')
// Get user1's org
const allOrgs = await db.all<{ id: string; metadata: string }>(sql`SELECT id, metadata FROM organization`)
const org1 = allOrgs.find((r) => {
try {
return (JSON.parse(r.metadata) as { type?: string }).type === 'personal'
} catch {
return false
}
})
if (!org1) throw new Error('personal org for user1 not found')
const orgId1 = org1.id
// Sign up user2 to create a second org
await authedHeaders(app, 'user2@example.com')
const userId2 = await getUserId(db, 'user2@example.com')
const allOrgs2 = await db.all<{ id: string; metadata: string }>(sql`SELECT id, metadata FROM organization`)
const orgsWithPersonal = allOrgs2.filter((r) => {
try {
return (JSON.parse(r.metadata) as { type?: string }).type === 'personal'
} catch {
return false
}
})
const org2 = orgsWithPersonal.find((r) => r.id !== orgId1)
if (!org2) throw new Error('personal org for user2 not found')
const orgId2 = org2.id
await insertActivityEvent(db, { id: 'evt-org1', orgId: orgId1, userId: userId1, targetName: 'user1-file.pdf' })
await insertActivityEvent(db, { id: 'evt-org2', orgId: orgId2, userId: userId2, targetName: 'user2-file.pdf' })
const res = await app.request(`/api/teams/${orgId1}/activity`, { headers: headers1 })
const body = (await res.json()) as { items: Array<{ id: string }>; total: number }
expect(body.total).toBe(1)
expect(body.items[0].id).toBe('evt-org1')
})
it('returns items with all expected fields', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const orgId = await getOrgId(db)
const userId = await getUserId(db)
await insertActivityEvent(db, {
id: 'evt-fields',
orgId,
userId,
action: 'move',
targetType: 'folder',
targetId: 'folder-1',
targetName: 'My Folder',
metadata: JSON.stringify({ from: '/old', to: '/new' }),
})
const res = await app.request(`/api/teams/${orgId}/activity`, { headers })
const body = (await res.json()) as { items: Array<Record<string, unknown>>; total: number }
const item = body.items[0]
expect(item.id).toBe('evt-fields')
expect(item.action).toBe('move')
expect(item.targetType).toBe('folder')
expect(item.targetId).toBe('folder-1')
expect(item.targetName).toBe('My Folder')
expect(item.metadata).toBe(JSON.stringify({ from: '/old', to: '/new' }))
expect(item.user).toBeDefined()
})
})
+33
View File
@@ -0,0 +1,33 @@
import { zValidator } from '@hono/zod-validator'
import { Hono } from 'hono'
import { z } from 'zod'
import { requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { listActivities } from '../services/activity'
import { getMemberRole, isPersonalOrg } from '../services/org'
const activityQuerySchema = z.object({
page: z.string().optional(),
pageSize: z.string().optional(),
})
const app = new Hono<Env>()
.use(requireAuth)
.get('/:teamId/activity', zValidator('query', activityQuerySchema), async (c) => {
const userId = c.get('userId')!
const teamId = c.req.param('teamId')
const db = c.get('platform').db
const role = await getMemberRole(db, teamId, userId)
if (role === null && !(await isPersonalOrg(db, teamId))) {
return c.json({ error: 'Forbidden' }, 403)
}
const { page: pageStr, pageSize: pageSizeStr } = c.req.valid('query')
const page = Number(pageStr ?? '1')
const pageSize = Number(pageSizeStr ?? '20')
const result = await listActivities(db, teamId, { page, pageSize })
return c.json({ ...result, page, pageSize })
})
export default app
+84
View File
@@ -0,0 +1,84 @@
import { count, desc, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { 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 }
}
+100 -7
View File
@@ -4,11 +4,13 @@ import { nanoid } from 'nanoid'
import { DirType } from '../../shared/constants'
import { matters, orgQuotas, storages } from '../db/schema'
import type { Database } from '../platform/interface'
import { recordActivity } from './activity'
export type Matter = typeof matters.$inferSelect
interface CreateMatterInput {
orgId: string
userId?: string
name: string
type: string
size?: number
@@ -41,6 +43,19 @@ export async function createMatter(db: Database, input: CreateMatterInput): Prom
}
await db.insert(matters).values(row)
if (input.userId) {
const isFolder = (input.dirtype ?? 0) !== DirType.FILE
await recordActivity(db, {
orgId: input.orgId,
userId: input.userId,
action: isFolder ? 'create' : 'upload',
targetType: isFolder ? 'folder' : 'file',
targetId: row.id,
targetName: row.name,
})
}
return row
}
@@ -122,6 +137,7 @@ export async function updateMatter(
id: string,
orgId: string,
input: { name?: string; parent?: string; isPublic?: boolean },
userId?: string,
): Promise<Matter | null> {
const existing = await getMatter(db, id, orgId)
if (!existing) return null
@@ -148,7 +164,36 @@ export async function updateMatter(
.set({ name: newName, parent: newParent, isPublic: newIsPublic, updatedAt: now })
.where(and(eq(matters.id, id), eq(matters.orgId, orgId)))
return { ...existing, name: newName, parent: newParent, isPublic: newIsPublic, updatedAt: now }
const updated = { ...existing, name: newName, parent: newParent, isPublic: newIsPublic, updatedAt: now }
if (userId) {
const isFolder = existing.dirtype !== DirType.FILE
const targetType = isFolder ? 'folder' : 'file'
if (renamed) {
await recordActivity(db, {
orgId,
userId,
action: 'rename',
targetType,
targetId: id,
targetName: newName,
metadata: { from: existing.name },
})
}
if (moved) {
await recordActivity(db, {
orgId,
userId,
action: 'move',
targetType,
targetId: id,
targetName: newName,
metadata: { from: existing.parent, to: newParent },
})
}
}
return updated
}
export async function batchUpdateVisibility(
@@ -296,7 +341,13 @@ export async function getMatters(db: Database, orgId: string, ids: string[]): Pr
.where(and(eq(matters.orgId, orgId), inArray(matters.id, ids)))
}
export async function batchMove(db: Database, orgId: string, ids: string[], newParent: string): Promise<Matter[]> {
export async function batchMove(
db: Database,
orgId: string,
ids: string[],
newParent: string,
userId?: string,
): Promise<Matter[]> {
const uniqueIds = [...new Set(ids)]
const items = await getMatters(db, orgId, uniqueIds)
if (items.length !== uniqueIds.length) {
@@ -326,7 +377,23 @@ export async function batchMove(db: Database, orgId: string, ids: string[], newP
.where(and(eq(matters.id, item.id), eq(matters.orgId, orgId)))
}
return items.map((m) => ({ ...m, parent: newParent, updatedAt: now }))
const moved = items.map((m) => ({ ...m, parent: newParent, updatedAt: now }))
if (userId) {
for (const item of items) {
await recordActivity(db, {
orgId,
userId,
action: 'move',
targetType: item.dirtype !== DirType.FILE ? 'folder' : 'file',
targetId: item.id,
targetName: item.name,
metadata: { from: item.parent, to: newParent },
})
}
}
return moved
}
function getDescendants(db: Database, orgId: string, folderPath: string): Promise<Matter[]> {
@@ -394,7 +461,7 @@ export async function batchDelete(db: Database, orgId: string, ids: string[]): P
// ─── Recycle Bin ─────────────────────────────────────────────────────────────
export async function trashMatter(db: Database, orgId: string, id: string): Promise<Matter | null> {
export async function trashMatter(db: Database, orgId: string, id: string, userId?: string): Promise<Matter | null> {
const existing = await getMatter(db, id, orgId)
if (!existing) return null
if (existing.status === 'trashed') return existing
@@ -416,10 +483,23 @@ export async function trashMatter(db: Database, orgId: string, id: string): Prom
.set({ status: 'trashed', trashedAt: nowTs, updatedAt: now })
.where(and(eq(matters.id, targetId), eq(matters.orgId, orgId), eq(matters.status, 'active')))
}
return { ...existing, status: 'trashed', trashedAt: nowTs, updatedAt: now }
const trashed = { ...existing, status: 'trashed', trashedAt: nowTs, updatedAt: now }
if (userId) {
await recordActivity(db, {
orgId,
userId,
action: 'delete',
targetType: existing.dirtype !== DirType.FILE ? 'folder' : 'file',
targetId: existing.id,
targetName: existing.name,
})
}
return trashed
}
export async function restoreMatter(db: Database, orgId: string, id: string): Promise<Matter | null> {
export async function restoreMatter(db: Database, orgId: string, id: string, userId?: string): Promise<Matter | null> {
const existing = await getMatter(db, id, orgId)
if (!existing) return null
if (existing.status !== 'trashed') return existing
@@ -440,7 +520,20 @@ export async function restoreMatter(db: Database, orgId: string, id: string): Pr
.set({ status: 'active', trashedAt: null, updatedAt: now })
.where(and(eq(matters.id, targetId), eq(matters.orgId, orgId), eq(matters.status, 'trashed')))
}
return { ...existing, status: 'active', trashedAt: null, updatedAt: now }
const restored = { ...existing, status: 'active', trashedAt: null, updatedAt: now }
if (userId) {
await recordActivity(db, {
orgId,
userId,
action: 'restore',
targetType: existing.dirtype !== DirType.FILE ? 'folder' : 'file',
targetId: existing.id,
targetName: existing.name,
})
}
return restored
}
export async function collectForPurge(db: Database, orgId: string, id: string): Promise<Matter[] | null>
+12
View File
@@ -147,6 +147,18 @@ const APP_SCHEMA_SQL = `
expires_at INTEGER,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS activity_events (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL,
user_id TEXT NOT NULL,
action TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT,
target_name TEXT NOT NULL,
metadata TEXT,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS activity_events_org_id_idx ON activity_events(org_id);
`
export async function createTestApp() {
+17
View File
@@ -90,3 +90,20 @@ export interface PaginatedResponse<T> {
page: number
pageSize: number
}
export interface ActivityEvent {
id: string
orgId: string
userId: string
action: string
targetType: string
targetId: string | null
targetName: string
metadata: string | null
createdAt: string
user: {
id: string
name: string
image: string | null
}
}
+15 -1
View File
@@ -312,5 +312,19 @@
"teams.role.admin": "Admin",
"teams.role.member": "Member",
"org.mySpace": "My Space",
"org.switchWorkspace": "Switch Workspace"
"org.switchWorkspace": "Switch Workspace",
"activity.title": "Activity",
"activity.empty": "No activity yet",
"activity.loadError": "Failed to load activity",
"activity.action.upload": "uploaded",
"activity.action.create": "created",
"activity.action.delete": "deleted",
"activity.action.rename": "renamed",
"activity.action.move": "moved",
"activity.action.restore": "restored",
"activity.target.file": "file",
"activity.target.folder": "folder",
"activity.meta.from": "from",
"activity.meta.to": "to",
"activity.loadMore": "Load more"
}
+15 -1
View File
@@ -312,5 +312,19 @@
"teams.role.admin": "管理员",
"teams.role.member": "成员",
"org.mySpace": "我的空间",
"org.switchWorkspace": "切换工作区"
"org.switchWorkspace": "切换工作区",
"activity.title": "动态",
"activity.empty": "暂无操作记录",
"activity.loadError": "加载动态失败",
"activity.action.upload": "上传了",
"activity.action.create": "创建了",
"activity.action.delete": "删除了",
"activity.action.rename": "重命名了",
"activity.action.move": "移动了",
"activity.action.restore": "恢复了",
"activity.target.file": "文件",
"activity.target.folder": "文件夹",
"activity.meta.from": "从",
"activity.meta.to": "到",
"activity.loadMore": "加载更多"
}
+10 -1
View File
@@ -1,6 +1,6 @@
import type { OAuthProviderConfig } from '@shared/oauth-providers'
import type { CreateStorageInput, UpdateStorageInput } from '@shared/schemas'
import type { AuthProvider, PaginatedResponse, Storage, StorageObject } from '@shared/types'
import type { ActivityEvent, AuthProvider, PaginatedResponse, Storage, StorageObject } from '@shared/types'
import {
adminQuotas,
authProviders,
@@ -10,6 +10,7 @@ import {
profiles,
storages,
system,
teamsApi,
trash,
userQuotas,
users,
@@ -295,6 +296,14 @@ export function browseProfile(username: string, dir: string) {
)
}
// Teams Activity API
export function listTeamActivities(teamId: string, page = 1, pageSize = 20) {
return unwrap<PaginatedResponse<ActivityEvent>>(
teamsApi[':teamId'].activity.$get({ param: { teamId }, query: { page: String(page), pageSize: String(pageSize) } }),
)
}
// Auth API — Better Auth passthrough, not typed via Hono RPC
export async function getSession(): Promise<{ session: unknown; user: unknown } | null> {
const res = await fetch('/api/auth/get-session', { credentials: 'include' })
+2
View File
@@ -7,6 +7,7 @@ import type {
ProfileRoute,
StoragesRoute,
SystemRoute,
TeamsRoute,
TrashRoute,
UserQuotasRoute,
UsersRoute,
@@ -26,3 +27,4 @@ export const authProviders = hc<AuthProvidersRoute>('/api/auth-providers', opts)
export const inviteCodes = hc<AdminInviteCodesRoute>('/api/admin/invite-codes', opts)
export const emailConfig = hc<EmailConfigRoute>('/api/admin/email-config', opts)
export const profiles = hc<ProfileRoute>('/api/profiles')
export const teamsApi = hc<TeamsRoute>('/api/teams', opts)
+22
View File
@@ -26,6 +26,7 @@ import { Route as AuthenticatedAdminStoragesIndexRouteImport } from './routes/_a
import { Route as AuthenticatedAdminSettingsIndexRouteImport } from './routes/_authenticated/admin/settings/index'
import { Route as AuthenticatedTeamsTeamIdSettingsRouteImport } from './routes/_authenticated/teams/$teamId/settings'
import { Route as AuthenticatedTeamsTeamIdMembersRouteImport } from './routes/_authenticated/teams/$teamId/members'
import { Route as AuthenticatedTeamsTeamIdActivityRouteImport } from './routes/_authenticated/teams/$teamId/activity'
import { Route as AuthenticatedAdminSettingsAuthRouteImport } from './routes/_authenticated/admin/settings/auth'
const AuthenticatedRouteRoute = AuthenticatedRouteRouteImport.update({
@@ -120,6 +121,12 @@ const AuthenticatedTeamsTeamIdMembersRoute =
path: '/teams/$teamId/members',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedTeamsTeamIdActivityRoute =
AuthenticatedTeamsTeamIdActivityRouteImport.update({
id: '/teams/$teamId/activity',
path: '/teams/$teamId/activity',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedAdminSettingsAuthRoute =
AuthenticatedAdminSettingsAuthRouteImport.update({
id: '/settings/auth',
@@ -140,6 +147,7 @@ export interface FileRoutesByFullPath {
'/teams/': typeof AuthenticatedTeamsIndexRoute
'/users/': typeof AuthenticatedUsersIndexRoute
'/admin/settings/auth': typeof AuthenticatedAdminSettingsAuthRoute
'/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
'/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
'/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
'/admin/settings/': typeof AuthenticatedAdminSettingsIndexRoute
@@ -159,6 +167,7 @@ export interface FileRoutesByTo {
'/teams': typeof AuthenticatedTeamsIndexRoute
'/users': typeof AuthenticatedUsersIndexRoute
'/admin/settings/auth': typeof AuthenticatedAdminSettingsAuthRoute
'/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
'/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
'/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
'/admin/settings': typeof AuthenticatedAdminSettingsIndexRoute
@@ -180,6 +189,7 @@ export interface FileRoutesById {
'/_authenticated/teams/': typeof AuthenticatedTeamsIndexRoute
'/_authenticated/users/': typeof AuthenticatedUsersIndexRoute
'/_authenticated/admin/settings/auth': typeof AuthenticatedAdminSettingsAuthRoute
'/_authenticated/teams/$teamId/activity': typeof AuthenticatedTeamsTeamIdActivityRoute
'/_authenticated/teams/$teamId/members': typeof AuthenticatedTeamsTeamIdMembersRoute
'/_authenticated/teams/$teamId/settings': typeof AuthenticatedTeamsTeamIdSettingsRoute
'/_authenticated/admin/settings/': typeof AuthenticatedAdminSettingsIndexRoute
@@ -201,6 +211,7 @@ export interface FileRouteTypes {
| '/teams/'
| '/users/'
| '/admin/settings/auth'
| '/teams/$teamId/activity'
| '/teams/$teamId/members'
| '/teams/$teamId/settings'
| '/admin/settings/'
@@ -220,6 +231,7 @@ export interface FileRouteTypes {
| '/teams'
| '/users'
| '/admin/settings/auth'
| '/teams/$teamId/activity'
| '/teams/$teamId/members'
| '/teams/$teamId/settings'
| '/admin/settings'
@@ -240,6 +252,7 @@ export interface FileRouteTypes {
| '/_authenticated/teams/'
| '/_authenticated/users/'
| '/_authenticated/admin/settings/auth'
| '/_authenticated/teams/$teamId/activity'
| '/_authenticated/teams/$teamId/members'
| '/_authenticated/teams/$teamId/settings'
| '/_authenticated/admin/settings/'
@@ -375,6 +388,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedTeamsTeamIdMembersRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/teams/$teamId/activity': {
id: '/_authenticated/teams/$teamId/activity'
path: '/teams/$teamId/activity'
fullPath: '/teams/$teamId/activity'
preLoaderRoute: typeof AuthenticatedTeamsTeamIdActivityRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/admin/settings/auth': {
id: '/_authenticated/admin/settings/auth'
path: '/settings/auth'
@@ -414,6 +434,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedStoragesIndexRoute: typeof AuthenticatedStoragesIndexRoute
AuthenticatedTeamsIndexRoute: typeof AuthenticatedTeamsIndexRoute
AuthenticatedUsersIndexRoute: typeof AuthenticatedUsersIndexRoute
AuthenticatedTeamsTeamIdActivityRoute: typeof AuthenticatedTeamsTeamIdActivityRoute
AuthenticatedTeamsTeamIdMembersRoute: typeof AuthenticatedTeamsTeamIdMembersRoute
AuthenticatedTeamsTeamIdSettingsRoute: typeof AuthenticatedTeamsTeamIdSettingsRoute
}
@@ -427,6 +448,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedStoragesIndexRoute: AuthenticatedStoragesIndexRoute,
AuthenticatedTeamsIndexRoute: AuthenticatedTeamsIndexRoute,
AuthenticatedUsersIndexRoute: AuthenticatedUsersIndexRoute,
AuthenticatedTeamsTeamIdActivityRoute: AuthenticatedTeamsTeamIdActivityRoute,
AuthenticatedTeamsTeamIdMembersRoute: AuthenticatedTeamsTeamIdMembersRoute,
AuthenticatedTeamsTeamIdSettingsRoute: AuthenticatedTeamsTeamIdSettingsRoute,
}
@@ -0,0 +1,146 @@
import type { ActivityEvent } from '@shared/types'
import { useInfiniteQuery } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Button } from '@/components/ui/button'
import { listTeamActivities } from '@/lib/api'
export const Route = createFileRoute('/_authenticated/teams/$teamId/activity')({
component: TeamActivityPage,
})
function relativeTime(timestamp: string | Date): string {
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp
const now = Date.now()
const diffMs = now - date.getTime()
const diffSec = Math.floor(diffMs / 1000)
const diffMin = Math.floor(diffSec / 60)
const diffHour = Math.floor(diffMin / 60)
const diffDay = Math.floor(diffHour / 24)
if (diffSec < 60) return 'just now'
if (diffMin < 60) return `${diffMin} minute${diffMin !== 1 ? 's' : ''} ago`
if (diffHour < 24) return `${diffHour} hour${diffHour !== 1 ? 's' : ''} ago`
if (diffDay === 1) return 'yesterday'
if (diffDay < 30) return `${diffDay} days ago`
return date.toLocaleDateString()
}
function userInitials(name: string): string {
return name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
}
function ActivityItem({ event }: { event: ActivityEvent }) {
const { t } = useTranslation()
const actionLabel = t(`activity.action.${event.action}`, { defaultValue: event.action })
const targetTypeLabel = t(`activity.target.${event.targetType}`, { defaultValue: event.targetType })
let detail = `${actionLabel} ${targetTypeLabel} "${event.targetName}"`
if (event.metadata) {
try {
const meta = JSON.parse(event.metadata) as Record<string, string>
if (event.action === 'rename' && meta.from) {
detail += ` (${t('activity.meta.from')} "${meta.from}")`
} else if (event.action === 'move' && meta.to) {
detail += ` ${t('activity.meta.to')} ${meta.to}`
}
} catch {
// ignore malformed metadata
}
}
const createdAt = new Date(event.createdAt as unknown as string | number)
return (
<div className="flex items-start gap-3 py-3">
<Avatar className="h-8 w-8 flex-shrink-0">
{event.user.image && <AvatarImage src={event.user.image} alt={event.user.name} />}
<AvatarFallback className="text-xs">{userInitials(event.user.name || '?')}</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="text-sm">
<span className="font-medium">{event.user.name || event.userId}</span>{' '}
<span className="text-muted-foreground">{detail}</span>
</p>
<p className="text-xs text-muted-foreground">{relativeTime(createdAt)}</p>
</div>
</div>
)
}
function TeamActivityPage() {
const { t } = useTranslation()
const { teamId } = Route.useParams()
const PAGE_SIZE = 20
const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isPending, error } = useInfiniteQuery({
queryKey: ['team-activity', teamId],
queryFn: ({ pageParam = 1 }) => listTeamActivities(teamId, pageParam as number, PAGE_SIZE),
initialPageParam: 1,
getNextPageParam: (lastPage) => {
const loaded = (lastPage.page - 1) * lastPage.pageSize + lastPage.items.length
return loaded < lastPage.total ? lastPage.page + 1 : undefined
},
})
const allItems = data?.pages.flatMap((p) => p.items) ?? []
if (isPending) {
return (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="flex items-start gap-3 py-3">
<div className="h-8 w-8 flex-shrink-0 animate-pulse rounded-full bg-muted" />
<div className="flex-1 space-y-1.5">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
<div className="h-3 w-1/4 animate-pulse rounded bg-muted" />
</div>
</div>
))}
</div>
)
}
if (error) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{t('activity.loadError')}
</div>
)
}
return (
<div className="space-y-2">
<h2 className="text-xl font-semibold">{t('activity.title')}</h2>
{allItems.length === 0 ? (
<div className="rounded-md border border-dashed p-8 text-center text-muted-foreground">
{t('activity.empty')}
</div>
) : (
<div className="divide-y rounded-md border px-4">
{allItems.map((event) => (
<ActivityItem key={event.id} event={event} />
))}
</div>
)}
{hasNextPage && (
<div className="pt-2 text-center">
<Button variant="outline" size="sm" onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
{isFetchingNextPage ? '...' : t('activity.loadMore')}
</Button>
</div>
)}
</div>
)
}