diff --git a/migrations/0007_activity_feed.sql b/migrations/0007_activity_feed.sql new file mode 100644 index 00000000..f368e6eb --- /dev/null +++ b/migrations/0007_activity_feed.sql @@ -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`); diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 6299354a..b68fecdb 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -50,6 +50,13 @@ "when": 1775120000000, "tag": "0006_public_profile", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1776000000000, + "tag": "0007_activity_feed", + "breakpoints": true } ] } diff --git a/server/app.ts b/server/app.ts index ca527717..c0e7c260 100644 --- a/server/app.ts +++ b/server/app.ts @@ -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 diff --git a/server/db/schema.ts b/server/db/schema.ts index f3a8eb10..6cbd4fab 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -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(), +}) diff --git a/server/routes/objects.ts b/server/routes/objects.ts index d8100c2e..dcfcaabb 100644 --- a/server/routes/objects.ts +++ b/server/routes/objects.ts @@ -77,6 +77,7 @@ const app = new Hono() const matter = await createMatter(db, { orgId, + userId, name, type: isFolder ? 'folder' : type, size: isFolder ? 0 : size, @@ -98,8 +99,9 @@ const app = new Hono() 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() 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() 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() 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) }) diff --git a/server/routes/teams.integration.test.ts b/server/routes/teams.integration.test.ts new file mode 100644 index 00000000..bee18bf7 --- /dev/null +++ b/server/routes/teams.integration.test.ts @@ -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>['db'] +type AppType = Awaited>['app'] + +async function getOrgId(db: DbType): Promise { + 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 { + 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>; 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() + }) +}) diff --git a/server/routes/teams.ts b/server/routes/teams.ts new file mode 100644 index 00000000..cf17ac7f --- /dev/null +++ b/server/routes/teams.ts @@ -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() + .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 diff --git a/server/services/activity.ts b/server/services/activity.ts new file mode 100644 index 00000000..713ef2bc --- /dev/null +++ b/server/services/activity.ts @@ -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 +} + +export async function recordActivity(db: Database, event: RecordActivityInput): Promise { + 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 } +} diff --git a/server/services/matter.ts b/server/services/matter.ts index 6221ad9d..f6bfd69a 100644 --- a/server/services/matter.ts +++ b/server/services/matter.ts @@ -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 { 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 { +export async function batchMove( + db: Database, + orgId: string, + ids: string[], + newParent: string, + userId?: string, +): Promise { 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 { @@ -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 { +export async function trashMatter(db: Database, orgId: string, id: string, userId?: string): Promise { 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 { +export async function restoreMatter(db: Database, orgId: string, id: string, userId?: string): Promise { 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 diff --git a/server/test/setup.ts b/server/test/setup.ts index 0293c700..e32b462f 100644 --- a/server/test/setup.ts +++ b/server/test/setup.ts @@ -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() { diff --git a/shared/types/index.ts b/shared/types/index.ts index 698de293..5f91c6f0 100644 --- a/shared/types/index.ts +++ b/shared/types/index.ts @@ -90,3 +90,20 @@ export interface PaginatedResponse { 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 + } +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index cf02dd03..fe87751e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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" } diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0c68d721..c5e6887c 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -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": "加载更多" } diff --git a/src/lib/api.ts b/src/lib/api.ts index d061955a..d8ec276c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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>( + 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' }) diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts index bec4d5fd..2d07a468 100644 --- a/src/lib/rpc.ts +++ b/src/lib/rpc.ts @@ -7,6 +7,7 @@ import type { ProfileRoute, StoragesRoute, SystemRoute, + TeamsRoute, TrashRoute, UserQuotasRoute, UsersRoute, @@ -26,3 +27,4 @@ export const authProviders = hc('/api/auth-providers', opts) export const inviteCodes = hc('/api/admin/invite-codes', opts) export const emailConfig = hc('/api/admin/email-config', opts) export const profiles = hc('/api/profiles') +export const teamsApi = hc('/api/teams', opts) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index a1e30997..406f58e9 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -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, } diff --git a/src/routes/_authenticated/teams/$teamId/activity.tsx b/src/routes/_authenticated/teams/$teamId/activity.tsx new file mode 100644 index 00000000..f839b24d --- /dev/null +++ b/src/routes/_authenticated/teams/$teamId/activity.tsx @@ -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 + 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 ( +
+ + {event.user.image && } + {userInitials(event.user.name || '?')} + +
+

+ {event.user.name || event.userId}{' '} + {detail} +

+

{relativeTime(createdAt)}

+
+
+ ) +} + +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 ( +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ) + } + + if (error) { + return ( +
+ {t('activity.loadError')} +
+ ) + } + + return ( +
+

{t('activity.title')}

+ + {allItems.length === 0 ? ( +
+ {t('activity.empty')} +
+ ) : ( +
+ {allItems.map((event) => ( + + ))} +
+ )} + + {hasNextPage && ( +
+ +
+ )} +
+ ) +}