From 3e82663d43d46b52571243c8d4909e2ddcfba55f Mon Sep 17 00:00:00 2001 From: opencode-bot Date: Fri, 24 Jul 2026 04:28:05 +0100 Subject: [PATCH] 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 --- app/api/activity/route.ts | 33 +++ app/api/contracts/deploy/route.ts | 20 ++ app/api/disputes/route.ts | 10 + app/api/escrow/dispute/route.ts | 15 ++ app/api/escrow/fund/route.ts | 14 ++ app/api/escrow/refund/route.ts | 14 ++ app/api/escrow/release/route.ts | 16 ++ app/api/milestones/[id]/approve/route.ts | 12 ++ app/api/milestones/[id]/route.ts | 10 + app/api/milestones/[id]/submit/route.ts | 10 + app/api/milestones/route.ts | 10 + app/dashboard/activity/page.tsx | 255 +++++++++++++++++++++++ components/dashboard/sidebar.tsx | 6 + lib/activity/index.ts | 8 + lib/activity/service.ts | 165 +++++++++++++++ lib/activity/types.ts | 65 ++++++ lib/auth/constants.ts | 5 + lib/db/migrations/008_activity_logs.sql | 43 ++++ 18 files changed, 711 insertions(+) create mode 100644 app/api/activity/route.ts create mode 100644 app/dashboard/activity/page.tsx create mode 100644 lib/activity/index.ts create mode 100644 lib/activity/service.ts create mode 100644 lib/activity/types.ts create mode 100644 lib/db/migrations/008_activity_logs.sql diff --git a/app/api/activity/route.ts b/app/api/activity/route.ts new file mode 100644 index 0000000..884d701 --- /dev/null +++ b/app/api/activity/route.ts @@ -0,0 +1,33 @@ +export const dynamic = 'force-dynamic' + +import { NextRequest, NextResponse } from 'next/server' +import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' +import { activityService } from '@/lib/activity' + +export const GET = withRbac('activity:view', async (request: NextRequest, auth: RbacContext) => { + try { + const { searchParams } = new URL(request.url) + + const result = await activityService.list( + { + walletAddress: auth.walletAddress, + limitParam: searchParams.get('limit'), + offsetParam: searchParams.get('offset'), + contractId: searchParams.get('contractId'), + projectId: searchParams.get('projectId'), + actionType: searchParams.get('actionType'), + actorId: searchParams.get('actorId'), + }, + auth.userId, + auth.role + ) + + return NextResponse.json(result) + } catch (err) { + console.error('[activity] Failed to list activity logs:', err) + return NextResponse.json( + { error: 'Failed to fetch activity logs', code: 'ACTIVITY_FETCH_FAILED' }, + { status: 500 } + ) + } +}) \ No newline at end of file diff --git a/app/api/contracts/deploy/route.ts b/app/api/contracts/deploy/route.ts index 45ecf3f..ab07702 100644 --- a/app/api/contracts/deploy/route.ts +++ b/app/api/contracts/deploy/route.ts @@ -2,7 +2,9 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { withAuth } from '@/lib/auth/middleware' +import { sql } from '@/lib/db' import { deploySorobanEscrow, SorobanDeployError } from '@/lib/soroban/deploy' +import { activityService } from '@/lib/activity' import { createContract, createMilestones, @@ -164,6 +166,24 @@ export const POST = withAuth(async (request: NextRequest, auth) => { return NextResponse.json({ error: 'Failed to persist contract data', code: 'DB_ERROR' }, { status: 500 }) } + const users = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1` + const actorId = users[0]?.id as string | undefined + if (actorId) { + activityService.log({ + actorId, + contractId: String(contract.id), + projectId: String(job.id), + actionType: 'contract_created', + description: `Contract created for project "${job.title}" with freelancer ${freelancer.wallet_address}`, + metadata: { + totalAmount: body.totalAmount, + currency, + milestonesCount: milestones.length, + freelancerId: body.freelancerId, + }, + }).catch((err: unknown) => console.error('[activity] Failed to log contract_created:', err)) + } + return NextResponse.json( { contractId: contract.id, diff --git a/app/api/disputes/route.ts b/app/api/disputes/route.ts index f60de9c..f73b5b6 100644 --- a/app/api/disputes/route.ts +++ b/app/api/disputes/route.ts @@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { sql } from '@/lib/db' import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' +import { activityService } from '@/lib/activity' export const POST = withRbac('dispute:create', async (request: NextRequest, auth: RbacContext) => { try { @@ -15,6 +16,15 @@ export const POST = withRbac('dispute:create', async (request: NextRequest, auth if (!job) return NextResponse.json({ error: 'Job not found', code: 'JOB_NOT_FOUND' }, { status: 404 }) const [dispute] = await sql`INSERT INTO disputes (job_id, raised_by, reason) VALUES (${job.id}, ${auth.userId}, ${reason}) RETURNING *` await sql`UPDATE jobs SET status = 'disputed', updated_at = CURRENT_TIMESTAMP WHERE id = ${jobId}` + + activityService.log({ + actorId: auth.userId, + disputeId: dispute.id, + actionType: 'dispute_created', + description: `Dispute raised on job "${job.title}": "${reason}"`, + metadata: { jobId, reason }, + }).catch((err: unknown) => console.error('[activity] Failed to log dispute_created:', err)) + return NextResponse.json(dispute, { status: 201 }) } catch { return NextResponse.json({ error: 'Failed to raise dispute', code: 'DISPUTE_CREATION_FAILED' }, { status: 500 }) diff --git a/app/api/escrow/dispute/route.ts b/app/api/escrow/dispute/route.ts index 9f94f61..3a60c3d 100644 --- a/app/api/escrow/dispute/route.ts +++ b/app/api/escrow/dispute/route.ts @@ -23,6 +23,7 @@ import { escrowErrorToHttpStatus, type DisputeRaisedBy, } from '@/lib/escrow' +import { activityService } from '@/lib/activity' export const POST = withAuth(async (request: NextRequest, auth) => { let body: Record @@ -63,6 +64,20 @@ export const POST = withAuth(async (request: NextRequest, auth) => { responseDeadline: body.responseDeadline as string | undefined, }) + activityService.log({ + actorId: userId, + contractId: result.contract.id, + disputeId: result.dispute.id, + milestoneId: result.dispute.milestoneId ?? undefined, + actionType: 'dispute_created', + description: `Dispute raised by ${raisedBy}: "${body.reason}"`, + metadata: { + raisedBy, + reason: body.reason, + desiredOutcome: body.desiredOutcome ?? null, + }, + }).catch((err: unknown) => console.error('[activity] Failed to log dispute_created:', err)) + return NextResponse.json( { disputeId: result.dispute.id, diff --git a/app/api/escrow/fund/route.ts b/app/api/escrow/fund/route.ts index 08e9138..4a0e92c 100644 --- a/app/api/escrow/fund/route.ts +++ b/app/api/escrow/fund/route.ts @@ -12,7 +12,9 @@ import { NextRequest, NextResponse } from 'next/server' import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' +import { sql } from '@/lib/db' import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow' +import { activityService } from '@/lib/activity' export const POST = withRbac('escrow:fund', async (request: NextRequest, auth: RbacContext) => { let body: Record @@ -33,6 +35,18 @@ export const POST = withRbac('escrow:fund', async (request: NextRequest, auth: R amount: body.amount as string, }) + activityService.log({ + actorId: auth.userId, + contractId: result.contract.id, + actionType: 'escrow_funded', + description: `Escrow funded with ${body.amount} for contract ${result.contract.id}`, + metadata: { + amount: body.amount, + fundingTxHash: body.fundingTxHash, + escrowStatus: result.contract.escrowStatus, + }, + }).catch((err: unknown) => console.error('[activity] Failed to log escrow_funded:', err)) + return NextResponse.json({ contractId: result.contract.id, escrowStatus: result.contract.escrowStatus, diff --git a/app/api/escrow/refund/route.ts b/app/api/escrow/refund/route.ts index a7d7cbd..2496b17 100644 --- a/app/api/escrow/refund/route.ts +++ b/app/api/escrow/refund/route.ts @@ -12,6 +12,7 @@ import { NextRequest, NextResponse } from 'next/server' import { withAnyRbac, RbacContext } from '@/lib/auth/rbacMiddleware' import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow' +import { activityService } from '@/lib/activity' export const POST = withAnyRbac(['escrow:refund', 'admin:contracts_freeze'], async (request: NextRequest, auth: RbacContext) => { let body: Record @@ -31,6 +32,19 @@ export const POST = withAnyRbac(['escrow:refund', 'admin:contracts_freeze'], asy reason: body.reason as string, }) + activityService.log({ + actorId: auth.userId, + contractId: result.contract.id, + actionType: 'escrow_refunded', + description: `Escrow refunded: "${body.reason}"`, + metadata: { + reason: body.reason, + refundTxHash: result.refundTxHash, + contractStatus: result.contract.status, + escrowStatus: result.contract.escrowStatus, + }, + }).catch((err: unknown) => console.error('[activity] Failed to log escrow_refunded:', err)) + return NextResponse.json({ contractId: result.contract.id, contractStatus: result.contract.status, diff --git a/app/api/escrow/release/route.ts b/app/api/escrow/release/route.ts index ecb6847..11c3701 100644 --- a/app/api/escrow/release/route.ts +++ b/app/api/escrow/release/route.ts @@ -12,6 +12,7 @@ import { NextRequest, NextResponse } from 'next/server' import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' import { escrowService, EscrowError, escrowErrorToHttpStatus } from '@/lib/escrow' +import { activityService } from '@/lib/activity' export const POST = withRbac('escrow:release', async (request: NextRequest, auth: RbacContext) => { let body: Record @@ -31,6 +32,21 @@ export const POST = withRbac('escrow:release', async (request: NextRequest, auth callerWalletAddress: auth.walletAddress, }) + activityService.log({ + actorId: auth.userId, + contractId: result.contract.id, + milestoneId: result.milestone.id, + actionType: 'payment_released', + description: `Payment of ${result.milestone.amount} released for milestone "${result.milestone.title}"`, + metadata: { + amount: result.milestone.amount, + currency: result.milestone.currency, + releaseTxHash: result.releaseTxHash, + milestoneStatus: result.milestone.status, + allMilestonesPaid: result.allMilestonesPaid, + }, + }).catch((err: unknown) => console.error('[activity] Failed to log payment_released:', err)) + return NextResponse.json({ milestoneId: result.milestone.id, milestoneStatus: result.milestone.status, diff --git a/app/api/milestones/[id]/approve/route.ts b/app/api/milestones/[id]/approve/route.ts index 6ea8719..3e3ac36 100644 --- a/app/api/milestones/[id]/approve/route.ts +++ b/app/api/milestones/[id]/approve/route.ts @@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { withAnyRbac, RbacContext } from '@/lib/auth/rbacMiddleware' import { sql } from '@/lib/db' +import { activityService } from '@/lib/activity' // Only the contract client can approve (or reject) a submitted milestone 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 RETURNING * ` + activityService.log({ + actorId: auth.userId, + milestoneId: id, + contractId: milestone.contract_id, + actionType: action === 'approve' ? 'milestone_approved' : 'milestone_rejected', + description: action === 'approve' + ? `Milestone "${updated.title}" approved` + : `Milestone "${updated.title}" rejected: "${rejection_reason}"`, + metadata: { action, rejection_reason: rejection_reason ?? null }, + }).catch((err: unknown) => console.error('[activity] Failed to log milestone approval:', err)) + return NextResponse.json({ milestone: updated }) } catch { return NextResponse.json({ error: 'Failed to process milestone approval', code: 'MILESTONE_APPROVE_FAILED' }, { status: 500 }) diff --git a/app/api/milestones/[id]/route.ts b/app/api/milestones/[id]/route.ts index 37e67bf..cb7154b 100644 --- a/app/api/milestones/[id]/route.ts +++ b/app/api/milestones/[id]/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server' import { withAuth } from '@/lib/auth/middleware' import { sql } from '@/lib/db' import { UpdateMilestoneSchema, IMMUTABLE_MILESTONE_STATUS_VALUES } from '@/lib/validations' +import { activityService } from '@/lib/activity' export const PATCH = withAuth(async (request: NextRequest, auth) => { const id = request.nextUrl.pathname.split('/').at(-1) @@ -65,6 +66,15 @@ export const PATCH = withAuth(async (request: NextRequest, auth) => { RETURNING * ` + activityService.log({ + actorId: user.id, + milestoneId: id, + projectId: milestone.project_id, + actionType: 'milestone_updated', + description: `Milestone "${updated.title}" updated`, + metadata: { previousTitle: milestone.title }, + }).catch((err: unknown) => console.error('[activity] Failed to log milestone_updated:', err)) + return NextResponse.json({ milestone: updated }) } catch { return NextResponse.json({ error: 'Failed to update milestone', code: 'MILESTONE_UPDATE_FAILED' }, { status: 500 }) diff --git a/app/api/milestones/[id]/submit/route.ts b/app/api/milestones/[id]/submit/route.ts index 3649195..ba253b5 100644 --- a/app/api/milestones/[id]/submit/route.ts +++ b/app/api/milestones/[id]/submit/route.ts @@ -3,6 +3,7 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { withRbac, RbacContext } from '@/lib/auth/rbacMiddleware' import { sql } from '@/lib/db' +import { activityService } from '@/lib/activity' // Only the contract freelancer can submit a milestone (status must be pending or in_progress) 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 RETURNING * ` + activityService.log({ + actorId: auth.userId, + milestoneId: id, + contractId: milestone.contract_id, + actionType: 'milestone_submitted', + description: `Milestone "${updated.title}" submitted for review`, + metadata: { deliverables: deliverables ?? [] }, + }).catch((err: unknown) => console.error('[activity] Failed to log milestone_submitted:', err)) + return NextResponse.json({ milestone: updated }) } catch { return NextResponse.json({ error: 'Failed to submit milestone', code: 'MILESTONE_SUBMIT_FAILED' }, { status: 500 }) diff --git a/app/api/milestones/route.ts b/app/api/milestones/route.ts index b36bb90..2e29152 100644 --- a/app/api/milestones/route.ts +++ b/app/api/milestones/route.ts @@ -4,6 +4,7 @@ import { NextRequest, NextResponse } from 'next/server' import { withAuth } from '@/lib/auth/middleware' import { sql } from '@/lib/db' import { CreateMilestoneSchema } from '@/lib/validations' +import { activityService } from '@/lib/activity' export const POST = withAuth(async (request: NextRequest, auth) => { let body: unknown @@ -50,6 +51,15 @@ export const POST = withAuth(async (request: NextRequest, auth) => { RETURNING * ` + activityService.log({ + actorId: user.id, + projectId: project_id, + milestoneId: milestone.id, + actionType: 'milestone_created', + description: `Milestone "${title}" created with amount ${amount} ${currency}`, + metadata: { amount, currency, sort_order }, + }).catch((err: unknown) => console.error('[activity] Failed to log milestone_created:', err)) + return NextResponse.json({ milestone }, { status: 201 }) } catch { return NextResponse.json({ error: 'Failed to create milestone', code: 'MILESTONE_CREATE_FAILED' }, { status: 500 }) diff --git a/app/dashboard/activity/page.tsx b/app/dashboard/activity/page.tsx new file mode 100644 index 0000000..5500065 --- /dev/null +++ b/app/dashboard/activity/page.tsx @@ -0,0 +1,255 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { + FileText, + AlertCircle, + Banknote, + CheckCircle2, + Clock, + XCircle, + ArrowLeft, + ArrowRight, + Loader2, + RefreshCw, +} from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' + +interface ActivityLog { + id: string + actorId: string + contractId: string | null + projectId: string | null + milestoneId: string | null + disputeId: string | null + actionType: string + description: string + metadata: Record + createdAt: string + actorUsername: string | null + actorWalletAddress: string | null + projectTitle: string | null +} + +interface ActivityLogPage { + logs: ActivityLog[] + pagination: { + limit: number + offset: number + total: number + nextOffset: number | null + hasMore: boolean + } +} + +const actionTypeConfig: Record = { + contract_created: { label: 'Contract Created', icon: FileText, color: 'text-blue-500', bgColor: 'bg-blue-500/10' }, + milestone_created: { label: 'Milestone Created', icon: FileText, color: 'text-indigo-500', bgColor: 'bg-indigo-500/10' }, + milestone_updated: { label: 'Milestone Updated', icon: FileText, color: 'text-purple-500', bgColor: 'bg-purple-500/10' }, + milestone_submitted: { label: 'Milestone Submitted', icon: Clock, color: 'text-amber-500', bgColor: 'bg-amber-500/10' }, + milestone_approved: { label: 'Milestone Approved', icon: CheckCircle2, color: 'text-green-500', bgColor: 'bg-green-500/10' }, + milestone_rejected: { label: 'Milestone Rejected', icon: XCircle, color: 'text-red-500', bgColor: 'bg-red-500/10' }, + escrow_funded: { label: 'Escrow Funded', icon: Banknote, color: 'text-emerald-500', bgColor: 'bg-emerald-500/10' }, + payment_released: { label: 'Payment Released', icon: CheckCircle2, color: 'text-green-500', bgColor: 'bg-green-500/10' }, + escrow_refunded: { label: 'Escrow Refunded', icon: Banknote, color: 'text-orange-500', bgColor: 'bg-orange-500/10' }, + dispute_created: { label: 'Dispute Raised', icon: AlertCircle, color: 'text-red-500', bgColor: 'bg-red-500/10' }, + dispute_resolved: { label: 'Dispute Resolved', icon: CheckCircle2, color: 'text-teal-500', bgColor: 'bg-teal-500/10' }, + contract_completed: { label: 'Contract Completed', icon: CheckCircle2, color: 'text-green-600', bgColor: 'bg-green-600/10' }, + contract_cancelled: { label: 'Contract Cancelled', icon: XCircle, color: 'text-gray-500', bgColor: 'bg-gray-500/10' }, +} + +function getAuthHeaders(): Record { + const token = typeof window !== 'undefined' ? localStorage.getItem('tc_dev_access_token') : null + return token ? { Authorization: `Bearer ${token}` } : {} +} + +function formatTimeAgo(dateStr: string): string { + const now = Date.now() + const date = new Date(dateStr).getTime() + const diffMs = now - date + const diffSec = Math.floor(diffMs / 1000) + if (diffSec < 60) return 'just now' + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin}m ago` + const diffHour = Math.floor(diffMin / 60) + if (diffHour < 24) return `${diffHour}h ago` + const diffDay = Math.floor(diffHour / 24) + if (diffDay < 7) return `${diffDay}d ago` + return new Date(dateStr).toLocaleDateString() +} + +export default function ActivityPage() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [offset, setOffset] = useState(0) + const [actionTypeFilter, setActionTypeFilter] = useState('all') + const limit = 20 + + const fetchActivity = useCallback(async (currentOffset: number) => { + setLoading(true) + try { + const params = new URLSearchParams() + params.set('limit', String(limit)) + params.set('offset', String(currentOffset)) + if (actionTypeFilter !== 'all') params.set('actionType', actionTypeFilter) + + const res = await fetch(`/api/activity?${params.toString()}`, { + headers: getAuthHeaders(), + credentials: 'include', + }) + if (!res.ok) return + setData(await res.json() as ActivityLogPage) + } finally { + setLoading(false) + } + }, [actionTypeFilter]) + + useEffect(() => { + setOffset(0) + fetchActivity(0) + }, [fetchActivity]) + + const goToPage = (newOffset: number) => { + setOffset(newOffset) + fetchActivity(newOffset) + } + + const logs = data?.logs ?? [] + const pagination = data?.pagination + + return ( +
+
+
+
+

Activity Log

+

+ Track all actions across your contracts and projects +

+
+
+ + +
+
+ + {loading && logs.length === 0 ? ( +
+ + Loading activity logs… +
+ ) : logs.length === 0 ? ( +
+
+ +
+

No activity logs found.

+
+ ) : ( + <> +
+ {logs.map((log) => { + const config = actionTypeConfig[log.actionType] + const Icon = config?.icon ?? Clock + const color = config?.color ?? 'text-muted-foreground' + const bgColor = config?.bgColor ?? 'bg-muted/30' + + return ( +
+
+
+ +
+
+
+ {config && ( + + {config.label} + + )} +
+

{log.description}

+
+ {log.actorUsername ?? log.actorWalletAddress ?? 'Unknown'} + {log.projectTitle && ( + <> + + {log.projectTitle} + + )} + + {formatTimeAgo(log.createdAt)} +
+
+
+
+ ) + })} +
+ + {pagination && pagination.total > limit && ( +
+ + + Page {Math.floor(offset / limit) + 1} of {Math.ceil(pagination.total / limit)} + + +
+ )} + + )} +
+
+ ) +} \ No newline at end of file diff --git a/components/dashboard/sidebar.tsx b/components/dashboard/sidebar.tsx index f0f28bd..ee92927 100644 --- a/components/dashboard/sidebar.tsx +++ b/components/dashboard/sidebar.tsx @@ -9,6 +9,7 @@ import { LogOut, ChevronRight, UserCircle, + History, } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -34,6 +35,11 @@ const navItems = [ href: '/dashboard/disputes', icon: AlertCircle, }, + { + label: 'Activity', + href: '/dashboard/activity', + icon: History, + }, { label: 'Profile', href: '/dashboard/profile', diff --git a/lib/activity/index.ts b/lib/activity/index.ts new file mode 100644 index 0000000..70cfd4d --- /dev/null +++ b/lib/activity/index.ts @@ -0,0 +1,8 @@ +export { activityService, ActivityService } from './service' +export type { + ActivityLog, + ActivityLogPage, + ActivityActionType, + CreateActivityLogInput, +} from './types' +export { activityActionTypes } from './types' \ No newline at end of file diff --git a/lib/activity/service.ts b/lib/activity/service.ts new file mode 100644 index 0000000..e13aa11 --- /dev/null +++ b/lib/activity/service.ts @@ -0,0 +1,165 @@ +import { z } from 'zod' +import { sql } from '@/lib/db' +import { + activityActionTypes, + type ActivityLog, + type ActivityLogPage, + type ActivityActionType, + type CreateActivityLogInput, + type ListActivityLogsParams, +} from './types' + +const uuidSchema = z.string().uuid() + +function normalizeLimit(value: string | null): number { + if (!value) return 50 + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 100) { + throw new Error('limit must be an integer between 1 and 100') + } + return parsed +} + +function normalizeOffset(value: string | null): number { + if (!value) return 0 + const parsed = Number(value) + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error('offset must be a non-negative integer') + } + return parsed +} + +function rowToLog(row: any): ActivityLog { + return { + id: row.id, + actorId: row.actor_id, + contractId: row.contract_id ?? null, + projectId: row.project_id ?? null, + milestoneId: row.milestone_id ?? null, + disputeId: row.dispute_id ?? null, + actionType: row.action_type, + description: row.description, + metadata: row.metadata ?? {}, + createdAt: row.created_at, + actorUsername: row.actor_username ?? null, + actorWalletAddress: row.actor_wallet_address ?? null, + projectTitle: row.project_title ?? null, + } +} + +export class ActivityService { + async log(input: CreateActivityLogInput): Promise { + const rows = await sql` + INSERT INTO activity_logs ( + actor_id, contract_id, project_id, milestone_id, dispute_id, + action_type, description, metadata + ) + VALUES ( + ${input.actorId}::uuid, + ${input.contractId ?? null}::uuid, + ${input.projectId ?? null}::uuid, + ${input.milestoneId ?? null}::uuid, + ${input.disputeId ?? null}::uuid, + ${input.actionType}::activity_action_type, + ${input.description}, + ${JSON.stringify(input.metadata ?? {})}::jsonb + ) + RETURNING * + ` + + const [log] = await sql` + SELECT l.*, + u.username AS actor_username, + u.wallet_address AS actor_wallet_address, + p.title AS project_title + FROM activity_logs l + LEFT JOIN users u ON u.id = l.actor_id + LEFT JOIN projects p ON p.id = l.project_id + WHERE l.id = ${rows[0].id} + ` + + return rowToLog(log) + } + + async list( + params: ListActivityLogsParams, + userId: string, + userRole: string + ): Promise { + const limit = normalizeLimit(params.limitParam) + const offset = normalizeOffset(params.offsetParam) + + if (params.contractId && !uuidSchema.safeParse(params.contractId).success) { + throw new Error('contractId must be a valid UUID') + } + if (params.projectId && !uuidSchema.safeParse(params.projectId).success) { + throw new Error('projectId must be a valid UUID') + } + if (params.actorId && !uuidSchema.safeParse(params.actorId).success) { + throw new Error('actorId must be a valid UUID') + } + if ( + params.actionType && + !activityActionTypes.includes(params.actionType as ActivityActionType) + ) { + throw new Error('actionType is not supported') + } + + const isAdmin = userRole === 'admin' + + const countRows = await sql` + SELECT COUNT(*)::int AS total_count + FROM activity_logs l + LEFT JOIN contracts c ON c.id = l.contract_id + WHERE (${isAdmin}::boolean + OR c.client_id = ${userId}::uuid + OR c.freelancer_id = ${userId}::uuid + OR l.actor_id = ${userId}::uuid) + AND (${params.contractId ?? null}::uuid IS NULL OR l.contract_id = ${params.contractId ?? null}::uuid) + AND (${params.projectId ?? null}::uuid IS NULL OR l.project_id = ${params.projectId ?? null}::uuid) + AND (${params.actorId ?? null}::uuid IS NULL OR l.actor_id = ${params.actorId ?? null}::uuid) + AND (${params.actionType ?? null}::activity_action_type IS NULL OR l.action_type = ${params.actionType ?? null}::activity_action_type) + ` + const total = Number(countRows[0]?.total_count ?? 0) + + const rows = total > offset + ? await sql` + SELECT l.*, + u.username AS actor_username, + u.wallet_address AS actor_wallet_address, + p.title AS project_title + FROM activity_logs l + LEFT JOIN contracts c ON c.id = l.contract_id + LEFT JOIN users u ON u.id = l.actor_id + LEFT JOIN projects p ON p.id = l.project_id + WHERE (${isAdmin}::boolean + OR c.client_id = ${userId}::uuid + OR c.freelancer_id = ${userId}::uuid + OR l.actor_id = ${userId}::uuid) + AND (${params.contractId ?? null}::uuid IS NULL OR l.contract_id = ${params.contractId ?? null}::uuid) + AND (${params.projectId ?? null}::uuid IS NULL OR l.project_id = ${params.projectId ?? null}::uuid) + AND (${params.actorId ?? null}::uuid IS NULL OR l.actor_id = ${params.actorId ?? null}::uuid) + AND (${params.actionType ?? null}::activity_action_type IS NULL OR l.action_type = ${params.actionType ?? null}::activity_action_type) + ORDER BY l.created_at DESC, l.id DESC + LIMIT ${limit} + OFFSET ${offset} + ` + : [] + + const logs = rows.map(rowToLog) + const nextOffset = offset + logs.length < total ? offset + logs.length : null + + return { + logs, + pagination: { + limit, + offset, + total, + nextOffset, + hasMore: nextOffset !== null, + }, + } + } +} + +export const activityService = new ActivityService() \ No newline at end of file diff --git a/lib/activity/types.ts b/lib/activity/types.ts new file mode 100644 index 0000000..acdd27b --- /dev/null +++ b/lib/activity/types.ts @@ -0,0 +1,65 @@ +export const activityActionTypes = [ + 'contract_created', + 'milestone_created', + 'milestone_updated', + 'milestone_submitted', + 'milestone_approved', + 'milestone_rejected', + 'escrow_funded', + 'payment_released', + 'escrow_refunded', + 'dispute_created', + 'dispute_resolved', + 'contract_completed', + 'contract_cancelled', +] as const + +export type ActivityActionType = (typeof activityActionTypes)[number] + +export interface ActivityLog { + id: string + actorId: string + contractId: string | null + projectId: string | null + milestoneId: string | null + disputeId: string | null + actionType: ActivityActionType + description: string + metadata: Record + createdAt: string + actorUsername: string | null + actorWalletAddress: string | null + projectTitle: string | null +} + +export interface ActivityLogPage { + logs: ActivityLog[] + pagination: { + limit: number + offset: number + total: number + nextOffset: number | null + hasMore: boolean + } +} + +export interface CreateActivityLogInput { + actorId: string + contractId?: string + projectId?: string + milestoneId?: string + disputeId?: string + actionType: ActivityActionType + description: string + metadata?: Record +} + +export interface ListActivityLogsParams { + walletAddress: string + limitParam: string | null + offsetParam: string | null + contractId?: string | null + projectId?: string | null + actionType?: string | null + actorId?: string | null +} \ No newline at end of file diff --git a/lib/auth/constants.ts b/lib/auth/constants.ts index 87dc34c..e9e99e8 100644 --- a/lib/auth/constants.ts +++ b/lib/auth/constants.ts @@ -37,6 +37,8 @@ export type Permission = | 'admin:users_manage' | 'admin:contracts_freeze' | 'admin:system_oversight' + // Activity log permissions + | 'activity:view' // General permissions | 'reputation:view' | 'reviews:create' @@ -52,6 +54,7 @@ export const ROLE_PERMISSIONS: Record = { 'contract:view', 'dispute:create', 'dispute:view', + 'activity:view', 'reputation:view', 'reviews:create', 'reviews:view', @@ -73,6 +76,7 @@ export const ROLE_PERMISSIONS: Record = { 'contract:update', 'dispute:create', 'dispute:view', + 'activity:view', 'reputation:view', 'reviews:create', 'reviews:view', @@ -97,6 +101,7 @@ export const ROLE_PERMISSIONS: Record = { 'dispute:create', 'dispute:view', 'dispute:resolve', + 'activity:view', 'admin:users_manage', 'admin:contracts_freeze', 'admin:system_oversight', diff --git a/lib/db/migrations/008_activity_logs.sql b/lib/db/migrations/008_activity_logs.sql new file mode 100644 index 0000000..1671c48 --- /dev/null +++ b/lib/db/migrations/008_activity_logs.sql @@ -0,0 +1,43 @@ +-- Activity Logs +-- System-wide activity tracking for transparency, auditability, and debugging. + +CREATE TYPE activity_action_type AS ENUM ( + 'contract_created', + 'milestone_created', + 'milestone_updated', + 'milestone_submitted', + 'milestone_approved', + 'milestone_rejected', + 'escrow_funded', + 'payment_released', + 'escrow_refunded', + 'dispute_created', + 'dispute_resolved', + 'contract_completed', + 'contract_cancelled' +); + +CREATE TABLE activity_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_id UUID NOT NULL REFERENCES users (id) ON DELETE RESTRICT, + contract_id UUID REFERENCES contracts (id) ON DELETE SET NULL, + project_id UUID REFERENCES projects (id) ON DELETE SET NULL, + milestone_id UUID REFERENCES milestones (id) ON DELETE SET NULL, + dispute_id UUID REFERENCES disputes (id) ON DELETE SET NULL, + action_type activity_action_type NOT NULL, + description TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Fast lookups by actor +CREATE INDEX idx_activity_logs_actor_id ON activity_logs (actor_id, created_at DESC); + +-- Fast lookups by contract +CREATE INDEX idx_activity_logs_contract_id ON activity_logs (contract_id, created_at DESC); + +-- Fast lookups by action type (for filtering) +CREATE INDEX idx_activity_logs_action_type ON activity_logs (action_type, created_at DESC); + +-- Time-ordered scans (dashboard timeline) +CREATE INDEX idx_activity_logs_created_at ON activity_logs (created_at DESC); \ No newline at end of file