Skip to content

Commit 3e82663

Browse files
author
opencode-bot
committed
feat: add Activity Logging System
- Add migration 008_activity_logs.sql with activity_logs table and indexes - Create lib/activity/ service with ActivityService (log + list with filtering/pagination) - Add activity:view permission to all roles in auth constants - Integrate logging into contract creation, milestone CRUD, escrow funding/release/refund, and dispute creation - Create GET /api/activity route (RBAC-protected, filterable by actionType, contractId, projectId, actorId) - Add Activity Log page in dashboard with type filtering, pagination, and timeline UI - Add Activity nav item to dashboard sidebar Closes #152
1 parent 63b0427 commit 3e82663

18 files changed

Lines changed: 711 additions & 0 deletions

File tree

app/api/activity/route.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
export const dynamic = 'force-dynamic'
2+
3+
import { NextRequest, NextResponse } from 'next/server'
4+
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
5+
import { activityService } from '@/lib/activity'
6+
7+
export const GET = withRbac('activity:view', async (request: NextRequest, auth: RbacContext) => {
8+
try {
9+
const { searchParams } = new URL(request.url)
10+
11+
const result = await activityService.list(
12+
{
13+
walletAddress: auth.walletAddress,
14+
limitParam: searchParams.get('limit'),
15+
offsetParam: searchParams.get('offset'),
16+
contractId: searchParams.get('contractId'),
17+
projectId: searchParams.get('projectId'),
18+
actionType: searchParams.get('actionType'),
19+
actorId: searchParams.get('actorId'),
20+
},
21+
auth.userId,
22+
auth.role
23+
)
24+
25+
return NextResponse.json(result)
26+
} catch (err) {
27+
console.error('[activity] Failed to list activity logs:', err)
28+
return NextResponse.json(
29+
{ error: 'Failed to fetch activity logs', code: 'ACTIVITY_FETCH_FAILED' },
30+
{ status: 500 }
31+
)
32+
}
33+
})

app/api/contracts/deploy/route.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ export const dynamic = 'force-dynamic'
22

33
import { NextRequest, NextResponse } from 'next/server'
44
import { withAuth } from '@/lib/auth/middleware'
5+
import { sql } from '@/lib/db'
56
import { deploySorobanEscrow, SorobanDeployError } from '@/lib/soroban/deploy'
7+
import { activityService } from '@/lib/activity'
68
import {
79
createContract,
810
createMilestones,
@@ -164,6 +166,24 @@ export const POST = withAuth(async (request: NextRequest, auth) => {
164166
return NextResponse.json({ error: 'Failed to persist contract data', code: 'DB_ERROR' }, { status: 500 })
165167
}
166168

169+
const users = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1`
170+
const actorId = users[0]?.id as string | undefined
171+
if (actorId) {
172+
activityService.log({
173+
actorId,
174+
contractId: String(contract.id),
175+
projectId: String(job.id),
176+
actionType: 'contract_created',
177+
description: `Contract created for project "${job.title}" with freelancer ${freelancer.wallet_address}`,
178+
metadata: {
179+
totalAmount: body.totalAmount,
180+
currency,
181+
milestonesCount: milestones.length,
182+
freelancerId: body.freelancerId,
183+
},
184+
}).catch((err: unknown) => console.error('[activity] Failed to log contract_created:', err))
185+
}
186+
167187
return NextResponse.json(
168188
{
169189
contractId: contract.id,

app/api/disputes/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
33
import { NextRequest, NextResponse } from 'next/server'
44
import { sql } from '@/lib/db'
55
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
6+
import { activityService } from '@/lib/activity'
67

78
export const POST = withRbac('dispute:create', async (request: NextRequest, auth: RbacContext) => {
89
try {
@@ -15,6 +16,15 @@ export const POST = withRbac('dispute:create', async (request: NextRequest, auth
1516
if (!job) return NextResponse.json({ error: 'Job not found', code: 'JOB_NOT_FOUND' }, { status: 404 })
1617
const [dispute] = await sql`INSERT INTO disputes (job_id, raised_by, reason) VALUES (${job.id}, ${auth.userId}, ${reason}) RETURNING *`
1718
await sql`UPDATE jobs SET status = 'disputed', updated_at = CURRENT_TIMESTAMP WHERE id = ${jobId}`
19+
20+
activityService.log({
21+
actorId: auth.userId,
22+
disputeId: dispute.id,
23+
actionType: 'dispute_created',
24+
description: `Dispute raised on job "${job.title}": "${reason}"`,
25+
metadata: { jobId, reason },
26+
}).catch((err: unknown) => console.error('[activity] Failed to log dispute_created:', err))
27+
1828
return NextResponse.json(dispute, { status: 201 })
1929
} catch {
2030
return NextResponse.json({ error: 'Failed to raise dispute', code: 'DISPUTE_CREATION_FAILED' }, { status: 500 })

app/api/escrow/dispute/route.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
escrowErrorToHttpStatus,
2424
type DisputeRaisedBy,
2525
} from '@/lib/escrow'
26+
import { activityService } from '@/lib/activity'
2627

2728
export const POST = withAuth(async (request: NextRequest, auth) => {
2829
let body: Record<string, unknown>
@@ -63,6 +64,20 @@ export const POST = withAuth(async (request: NextRequest, auth) => {
6364
responseDeadline: body.responseDeadline as string | undefined,
6465
})
6566

67+
activityService.log({
68+
actorId: userId,
69+
contractId: result.contract.id,
70+
disputeId: result.dispute.id,
71+
milestoneId: result.dispute.milestoneId ?? undefined,
72+
actionType: 'dispute_created',
73+
description: `Dispute raised by ${raisedBy}: "${body.reason}"`,
74+
metadata: {
75+
raisedBy,
76+
reason: body.reason,
77+
desiredOutcome: body.desiredOutcome ?? null,
78+
},
79+
}).catch((err: unknown) => console.error('[activity] Failed to log dispute_created:', err))
80+
6681
return NextResponse.json(
6782
{
6883
disputeId: result.dispute.id,

app/api/escrow/fund/route.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@
1212

1313
import { NextRequest, NextResponse } from 'next/server'
1414
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
15+
import { sql } from '@/lib/db'
1516
import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow'
17+
import { activityService } from '@/lib/activity'
1618

1719
export const POST = withRbac('escrow:fund', async (request: NextRequest, auth: RbacContext) => {
1820
let body: Record<string, unknown>
@@ -33,6 +35,18 @@ export const POST = withRbac('escrow:fund', async (request: NextRequest, auth: R
3335
amount: body.amount as string,
3436
})
3537

38+
activityService.log({
39+
actorId: auth.userId,
40+
contractId: result.contract.id,
41+
actionType: 'escrow_funded',
42+
description: `Escrow funded with ${body.amount} for contract ${result.contract.id}`,
43+
metadata: {
44+
amount: body.amount,
45+
fundingTxHash: body.fundingTxHash,
46+
escrowStatus: result.contract.escrowStatus,
47+
},
48+
}).catch((err: unknown) => console.error('[activity] Failed to log escrow_funded:', err))
49+
3650
return NextResponse.json({
3751
contractId: result.contract.id,
3852
escrowStatus: result.contract.escrowStatus,

app/api/escrow/refund/route.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { NextRequest, NextResponse } from 'next/server'
1313
import { withAnyRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
1414
import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow'
15+
import { activityService } from '@/lib/activity'
1516

1617
export const POST = withAnyRbac(['escrow:refund', 'admin:contracts_freeze'], async (request: NextRequest, auth: RbacContext) => {
1718
let body: Record<string, unknown>
@@ -31,6 +32,19 @@ export const POST = withAnyRbac(['escrow:refund', 'admin:contracts_freeze'], asy
3132
reason: body.reason as string,
3233
})
3334

35+
activityService.log({
36+
actorId: auth.userId,
37+
contractId: result.contract.id,
38+
actionType: 'escrow_refunded',
39+
description: `Escrow refunded: "${body.reason}"`,
40+
metadata: {
41+
reason: body.reason,
42+
refundTxHash: result.refundTxHash,
43+
contractStatus: result.contract.status,
44+
escrowStatus: result.contract.escrowStatus,
45+
},
46+
}).catch((err: unknown) => console.error('[activity] Failed to log escrow_refunded:', err))
47+
3448
return NextResponse.json({
3549
contractId: result.contract.id,
3650
contractStatus: result.contract.status,

app/api/escrow/release/route.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
import { NextRequest, NextResponse } from 'next/server'
1313
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
1414
import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow'
15+
import { activityService } from '@/lib/activity'
1516

1617
export const POST = withRbac('escrow:release', async (request: NextRequest, auth: RbacContext) => {
1718
let body: Record<string, unknown>
@@ -31,6 +32,21 @@ export const POST = withRbac('escrow:release', async (request: NextRequest, auth
3132
callerWalletAddress: auth.walletAddress,
3233
})
3334

35+
activityService.log({
36+
actorId: auth.userId,
37+
contractId: result.contract.id,
38+
milestoneId: result.milestone.id,
39+
actionType: 'payment_released',
40+
description: `Payment of ${result.milestone.amount} released for milestone "${result.milestone.title}"`,
41+
metadata: {
42+
amount: result.milestone.amount,
43+
currency: result.milestone.currency,
44+
releaseTxHash: result.releaseTxHash,
45+
milestoneStatus: result.milestone.status,
46+
allMilestonesPaid: result.allMilestonesPaid,
47+
},
48+
}).catch((err: unknown) => console.error('[activity] Failed to log payment_released:', err))
49+
3450
return NextResponse.json({
3551
milestoneId: result.milestone.id,
3652
milestoneStatus: result.milestone.status,

app/api/milestones/[id]/approve/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
33
import { NextRequest, NextResponse } from 'next/server'
44
import { withAnyRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
55
import { sql } from '@/lib/db'
6+
import { activityService } from '@/lib/activity'
67

78
// Only the contract client can approve (or reject) a submitted milestone
89
export const POST = withAnyRbac(['milestone:approve', 'milestone:reject'], async (request: NextRequest, auth: RbacContext) => {
@@ -59,6 +60,17 @@ export const POST = withAnyRbac(['milestone:approve', 'milestone:reject'], async
5960
RETURNING *
6061
`
6162

63+
activityService.log({
64+
actorId: auth.userId,
65+
milestoneId: id,
66+
contractId: milestone.contract_id,
67+
actionType: action === 'approve' ? 'milestone_approved' : 'milestone_rejected',
68+
description: action === 'approve'
69+
? `Milestone "${updated.title}" approved`
70+
: `Milestone "${updated.title}" rejected: "${rejection_reason}"`,
71+
metadata: { action, rejection_reason: rejection_reason ?? null },
72+
}).catch((err: unknown) => console.error('[activity] Failed to log milestone approval:', err))
73+
6274
return NextResponse.json({ milestone: updated })
6375
} catch {
6476
return NextResponse.json({ error: 'Failed to process milestone approval', code: 'MILESTONE_APPROVE_FAILED' }, { status: 500 })

app/api/milestones/[id]/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server'
44
import { withAuth } from '@/lib/auth/middleware'
55
import { sql } from '@/lib/db'
66
import { UpdateMilestoneSchema, IMMUTABLE_MILESTONE_STATUS_VALUES } from '@/lib/validations'
7+
import { activityService } from '@/lib/activity'
78

89
export const PATCH = withAuth(async (request: NextRequest, auth) => {
910
const id = request.nextUrl.pathname.split('/').at(-1)
@@ -65,6 +66,15 @@ export const PATCH = withAuth(async (request: NextRequest, auth) => {
6566
RETURNING *
6667
`
6768

69+
activityService.log({
70+
actorId: user.id,
71+
milestoneId: id,
72+
projectId: milestone.project_id,
73+
actionType: 'milestone_updated',
74+
description: `Milestone "${updated.title}" updated`,
75+
metadata: { previousTitle: milestone.title },
76+
}).catch((err: unknown) => console.error('[activity] Failed to log milestone_updated:', err))
77+
6878
return NextResponse.json({ milestone: updated })
6979
} catch {
7080
return NextResponse.json({ error: 'Failed to update milestone', code: 'MILESTONE_UPDATE_FAILED' }, { status: 500 })

app/api/milestones/[id]/submit/route.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic'
33
import { NextRequest, NextResponse } from 'next/server'
44
import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware'
55
import { sql } from '@/lib/db'
6+
import { activityService } from '@/lib/activity'
67

78
// Only the contract freelancer can submit a milestone (status must be pending or in_progress)
89
export const POST = withRbac('milestone:submit', async (request: NextRequest, auth: RbacContext) => {
@@ -45,6 +46,15 @@ export const POST = withRbac('milestone:submit', async (request: NextRequest, au
4546
RETURNING *
4647
`
4748

49+
activityService.log({
50+
actorId: auth.userId,
51+
milestoneId: id,
52+
contractId: milestone.contract_id,
53+
actionType: 'milestone_submitted',
54+
description: `Milestone "${updated.title}" submitted for review`,
55+
metadata: { deliverables: deliverables ?? [] },
56+
}).catch((err: unknown) => console.error('[activity] Failed to log milestone_submitted:', err))
57+
4858
return NextResponse.json({ milestone: updated })
4959
} catch {
5060
return NextResponse.json({ error: 'Failed to submit milestone', code: 'MILESTONE_SUBMIT_FAILED' }, { status: 500 })

0 commit comments

Comments
 (0)