mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
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:
@@ -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`);
|
||||
@@ -50,6 +50,13 @@
|
||||
"when": 1775120000000,
|
||||
"tag": "0006_public_profile",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "6",
|
||||
"when": 1776000000000,
|
||||
"tag": "0007_activity_feed",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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' })
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user