Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions app/api/escrow/logs/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* POST /api/escrow/logs
* GET /api/escrow/logs?limit=&offset=&contractId=&projectId=&transactionType=
*
* Stores and fetches escrow transaction audit logs for deposits, milestone
* releases, refunds, and disputes. Results are scoped to the authenticated
* user's contracts unless the caller is an admin.
*/

import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/lib/auth/middleware'
import {
escrowTransactionHistoryService,
EscrowError,
escrowErrorToHttpStatus,
} from '@/lib/escrow'

export const dynamic = 'force-dynamic'

export const POST = withAuth(async (request: NextRequest, auth) => {
let body: unknown
try {
body = await request.json()
} catch {
return NextResponse.json(
{ error: 'Request body must be valid JSON', code: 'INVALID_JSON' },
{ status: 400 }
)
}

try {
const log = await escrowTransactionHistoryService.createLog(
body,
auth.walletAddress
)

return NextResponse.json({ log }, { status: 201 })
} catch (err) {
if (err instanceof EscrowError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: escrowErrorToHttpStatus(err) }
)
}

console.error('[escrow/logs:POST] Unexpected error:', err)
return NextResponse.json(
{ error: 'Internal server error', code: 'INTERNAL_ERROR' },
{ status: 500 }
)
}
})

export const GET = withAuth(async (request: NextRequest, auth) => {
const searchParams = request.nextUrl.searchParams

try {
const result = await escrowTransactionHistoryService.listLogs({
walletAddress: auth.walletAddress,
limitParam: searchParams.get('limit'),
offsetParam: searchParams.get('offset'),
contractId: searchParams.get('contractId'),
projectId: searchParams.get('projectId'),
transactionType: searchParams.get('transactionType'),
})

return NextResponse.json(result)
} catch (err) {
if (err instanceof EscrowError) {
return NextResponse.json(
{ error: err.message, code: err.code },
{ status: escrowErrorToHttpStatus(err) }
)
}

console.error('[escrow/logs:GET] Unexpected error:', err)
return NextResponse.json(
{ error: 'Internal server error', code: 'INTERNAL_ERROR' },
{ status: 500 }
)
}
})
61 changes: 61 additions & 0 deletions lib/db/migrations/004_escrow_transaction_logs.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'escrow_transaction_type') THEN
CREATE TYPE escrow_transaction_type AS ENUM (
'deposit',
'milestone_release',
'refund',
'dispute'
);
END IF;
END;
$$;

CREATE TABLE IF NOT EXISTS escrow_transaction_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
contract_id UUID NOT NULL REFERENCES contracts (id) ON DELETE RESTRICT,
project_id UUID NOT NULL REFERENCES projects (id) ON DELETE RESTRICT,
milestone_id UUID REFERENCES milestones (id) ON DELETE SET NULL,
dispute_id UUID REFERENCES disputes (id) ON DELETE SET NULL,
actor_user_id UUID NOT NULL REFERENCES users (id) ON DELETE RESTRICT,
counterparty_user_id UUID REFERENCES users (id) ON DELETE SET NULL,
transaction_type escrow_transaction_type NOT NULL,
amount NUMERIC(18,6),
currency TEXT NOT NULL DEFAULT 'USDC',
transaction_hash TEXT,
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('pending', 'confirmed', 'failed')),
description TEXT,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),

CONSTRAINT chk_escrow_transaction_logs_money_amount
CHECK (
(transaction_type IN ('deposit', 'milestone_release', 'refund') AND amount IS NOT NULL AND amount > 0)
OR (transaction_type = 'dispute')
),
CONSTRAINT chk_escrow_transaction_logs_milestone_release
CHECK (transaction_type <> 'milestone_release' OR milestone_id IS NOT NULL),
CONSTRAINT chk_escrow_transaction_logs_dispute
CHECK (transaction_type <> 'dispute' OR dispute_id IS NOT NULL)
);

CREATE INDEX IF NOT EXISTS idx_escrow_transaction_logs_contract
ON escrow_transaction_logs (contract_id, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_escrow_transaction_logs_project
ON escrow_transaction_logs (project_id, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_escrow_transaction_logs_actor
ON escrow_transaction_logs (actor_user_id, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_escrow_transaction_logs_counterparty
ON escrow_transaction_logs (counterparty_user_id, created_at DESC)
WHERE counterparty_user_id IS NOT NULL;

CREATE INDEX IF NOT EXISTS idx_escrow_transaction_logs_type
ON escrow_transaction_logs (transaction_type, created_at DESC);

CREATE INDEX IF NOT EXISTS idx_escrow_transaction_logs_hash
ON escrow_transaction_logs (transaction_hash)
WHERE transaction_hash IS NOT NULL;
Loading
Loading