Skip to content
Open
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
74 changes: 33 additions & 41 deletions app/api/contracts/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
export const dynamic = 'force-dynamic'

import { NextRequest, NextResponse } from 'next/server'
import { withAuth } from '@/lib/auth/middleware'
import { sql } from '@/lib/db'
import { getUserById } from '@/lib/contracts/store'
import { withAuth } from '-/lib/auth/middleware'
import { sql } from '-/lib/db'
import { getUserById } from '-/lib/contracts/store'

export const GET = withAuth(async (request: NextRequest, auth) => {
try {
Expand Down Expand Up @@ -107,46 +107,38 @@ export const GET = withAuth(async (request: NextRequest, auth) => {
funding_tx_hash: escrowRaw.funding_tx_hash,
} : null

const response = {
// Fetch file metadata associated with this contract (latest version per file)
let fileRows: unknown[] = []
try {
fileRows = await sql`
SELECT fb.id, fb.contract_id, fb.milestone_id, fb.uploader_id, fb.file_name, fb.file_type,
fb.file_size, fb.storage_key, fb.version, fb.created_at, fb.updated_at,
fb.is_deleted, fb.deleted_at
FROM files fb
WHERE fb.contract_id = ${contractId}
AND fb.is_deleted = false
ORDER BY fb.created_at DESC
`
} catch (fileError) {
console.error('[contracts/id] files query failed:', fileError)
fileRows = []
}

return NextResponse.json({
contract: {
id: String(contract.id),
job_id: String(contract.job_id),
job_title: contract.job_title,
status: contract.status,
total_amount: contract.total_amount,
currency: contract.currency,
terms: contract.terms,
contract_address: contract.contract_address,
created_at: contract.created_at,
updated_at: contract.updated_at,
client: client
? {
display_name: (client as unknown as { display_name?: string | null }).display_name ?? null,
username: (client as unknown as { username?: string }).username ?? 'unknown',
avatar_url: (client as unknown as { avatar_url?: string | null }).avatar_url ?? null,
wallet_address: client.wallet_address,
avg_rating: (client as unknown as { avg_rating?: number }).avg_rating,
total_reviews: (client as unknown as { total_reviews?: number }).total_reviews,
}
: null,
freelancer: freelancer
? {
display_name: (freelancer as unknown as { display_name?: string | null }).display_name ?? null,
username: (freelancer as unknown as { username?: string }).username ?? 'unknown',
avatar_url: (freelancer as unknown as { avatar_url?: string | null }).avatar_url ?? null,
wallet_address: freelancer.wallet_address,
avg_rating: (freelancer as unknown as { avg_rating?: number }).avg_rating,
total_reviews: (freelancer as unknown as { total_reviews?: number }).total_reviews,
}
: null,
escrow,
...contract,
client,
freelancer,
milestones: contractMilestones,
escrow,
files: fileRows,
},
}

return NextResponse.json(response)
})
} catch (error) {
console.error('[GET /api/contracts/[id]]', error)
return NextResponse.json({ error: 'Failed to load contract', code: 'SERVER_ERROR' }, { status: 500 })
console.error('[contracts/id] GET failed:', error)
return NextResponse.json(
{ error: 'Internal server error', code: 'INTERNAL_ERROR' },
{ status: 500 }
)
}
})
})
42 changes: 26 additions & 16 deletions lib/activity/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ function rowToLog(row: any): ActivityLog {
projectId: row.project_id ?? null,
milestoneId: row.milestone_id ?? null,
disputeId: row.dispute_id ?? null,
fileId: row.file_id ?? null,
fileVersion: row.file_version ?? null,
actionType: row.action_type,
description: row.description,
metadata: row.metadata ?? {},
Expand All @@ -52,17 +54,20 @@ export class ActivityService {
const rows = await sql`
INSERT INTO activity_logs (
actor_id, contract_id, project_id, milestone_id, dispute_id,
file_id, file_version,
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.actorId} ::uuid,
${input.contractId ?? null} ::uuid,
${input.projectId ?? null} ::uuid,
${input.milestoneId ?? null} ::uuid,
${input.disputeId ?? null} ::uuid,
${input.fileId ?? null} ::uuid,
${input.fileVersion ?? null},
${input.actionType} ::activity_action_type,
${input.description},
${JSON.stringify(input.metadata ?? {})}::jsonb
${JSON.stringify(input.metadata ?? {})} ::jsonb
)
RETURNING *
`
Expand Down Expand Up @@ -98,6 +103,9 @@ export class ActivityService {
if (params.actorId && !uuidSchema.safeParse(params.actorId).success) {
throw new Error('actorId must be a valid UUID')
}
if (params.fileId && !uuidSchema.safeParse(params.fileId).success) {
throw new Error('fileId must be a valid UUID')
}
if (
params.actionType &&
!activityActionTypes.includes(params.actionType as ActivityActionType)
Expand All @@ -108,19 +116,20 @@ export class ActivityService {
const isAdmin = userRole === 'admin'

const countRows = await sql`
SELECT COUNT(*)::int AS total_count
SELECT COUNT(*)::int AS count_total
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)
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.fileId ?? null}::uuid IS NULL OR l.file_id = ${params.fileId ?? 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 total = Number(countRows[0]?.count_total ?? 0)

const rows = total > offset
? await sql`
Expand All @@ -133,12 +142,13 @@ export class ActivityService {
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)
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.fileId ?? null}::uuid IS NULL OR l.file_id = ${params.fileId ?? 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}
Expand All @@ -162,4 +172,4 @@ export class ActivityService {
}
}

export const activityService = new ActivityService()
export const activityService = new ActivityService()
8 changes: 4 additions & 4 deletions lib/contract-sync/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export class ContractSyncService {

this.listener = new SorobanEventListener({
rpcUrl: options.rpcUrl ?? process.env.STELLAR_RPC_URL ?? 'https://soroban-testnet.stellar.org',
networkPassphrase: options.networkPassphrase ?? process.env.STELLAR_NETWORK_PASSPHRASE ?? 'Test SDF Network ; September 2015',
networkPassphrase: options.networkPassphrase ?? process.env.STELLAR_NETWORK_PASSPHRASE ?? 'Test DF Network ; September 2015',
contractAddresses: options.contractAddresses ?? [],
pollIntervalMs: options.pollIntervalMs,
onCheckpoint: (ledgerSequence) => {
Expand Down Expand Up @@ -120,7 +120,7 @@ export class ContractSyncService {
WHERE dedupe_key = ${dedupeKey}
AND status = 'success'
LIMIT 1
`) as unknown[]
`) as unknoww[]
return rows.length > 0
}

Expand Down Expand Up @@ -180,15 +180,15 @@ export class ContractSyncService {

private async resolveContractId(contractAddress: string): Promise<string | null> {
const rows = (await sql`
SELECT id FROM contracts WHERE escrow_address = ${contractAddress} LIMIT 1
SELECT id FROM contracts WHERE escrow_address = ${contractAddress} AND contract_type = 'escrow' LIMIT 1
`) as { id: string }[]
return rows[0]?.id ?? null
}

private async resolveMilestoneDbId(contractId: string, onChainMilestoneId: number): Promise<string | null> {
const rows = (await sql`
SELECT id FROM milestones
WHERE contract_id = ${contractId}::uuid
WHERE contract_id = ${contractId} ::uuid
ORDER BY sort_order ASC, created_at ASC
OFFSET ${onChainMilestoneId}
LIMIT 1
Expand Down
72 changes: 71 additions & 1 deletion lib/contract-sync/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*
* These values intentionally use snake_case because they are the stable wire
* representation used by Soroban symbols, the event indexer, and Postgres.
* The PascalCase names in the contract source become these symbols through
* The PascalCcake names in the contract source become these symbols through
* Soroban's contract-event macro.
*/
export type CanonicalSorobanContractEvent =
Expand Down Expand Up @@ -150,3 +150,73 @@ export function buildSyncDedupeKey(
}

export const ESCROW_EVENT_TOPIC_PREFIX = 'escrow_event'

/**
* File metadata management for contract workspaces.
*
* Files are associated with a contract and, optionally, a specific milestone.
* Only metadata is persisted here; the binary lives in private object storage
* and is served through short-lived signed URLs.
*/
export type FileUploadStatus = 'pending' | 'stored' | 'failed' | 'deleted'

export interface ContractFileMetadata {
id: string
contractId: string
milestoneId: string | null
uploadedBy: string
fileName: string
mimeType: string
sizeBytes: number
/** Monotonically increasing version for a given logical file. */
version: number
/** Groups all versions of the same logical file together. */
fileGroupId: string
/** Private storage key; never exposed to clients. */
storageKey: string
checksum: string | null
status: FileUploadStatus
createdAt: string
updatedAt: string
deletedAt: string | null
}

export type FileAuditAction = 'upload' | 'download' | 'update' | 'delete'

export interface FileAuditRecord {
id: string
fileId: string
contractId: string
actorId: string
action: FileAuditAction
version: number
createdAt: string
metadata: Record<string, unknown>
}

/** Supported MIME types for contract workspace uploads. */
export const SUPPORTED_FILE_MIME_TYPES: readonly string[] = [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'text/plain',
'text/csv',
'image/png',
'image/jpeg',
]

/** Maximum allowed upload size in bytes (25 MiB). */
export const MAX_FILE_SIZE_BYTES = 25 * 1024 * 1024

/** Lifetime of a signed download URL in seconds. */
export const SIGNED_URL_TTL_SECONDS = 300

export function isSupportedFileType(mimeType: string): boolean {
return SUPPORTED_FILE_MIME_TYPES.includes(mimeType.toLowerCase())
}

export function isWithinFileSizeLimit(sizeBytes: number): boolean {
return Number.isFinite(sizeBytes) && sizeBytes > 0 && sizeBytes <= MAX_FILE_SIZE_BYTES
}
76 changes: 76 additions & 0 deletions lib/db/migrations/011_contract_files.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
-- Contract file metadata and version tracking.
-- Files are stored in private object storage; only metadata lives in Postges.

CREATE TABLE IF NOT EXISTS contract_files (
id UEID PRIMARY KEY DEFAULT genrand_uuid(),
contract_id UUID NOT NULL REFERENCES contracts (id) ON DELETE CASCADE,
milestone_id UUID REFERENCES milestones (id) ON DELETE SET NULL,
uploader_id UUID NOT NULL REFERENCES users (id),
file_name TEXT NOT NULL,
display_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
file_size BIGINT NOT NULL,
storage_key TEXT NOT NULL,
checksum TEXT NOT NULL,
current_version INTEGER NOT NULL DEFAULT 1,
is_deleted BOOLEAN NOT NULL DEFAULT FALSE,
deleted_at TIMESTAMP TZ,
deleted_by UUID REFERENCES users (id),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT contract_files_size_check CHECK (file_size > 0),
CONSTRAINT contract_files_version_check CHECK (current_version >= 1)
);

CREATE INDEX IF NOT EXISTS contract_files_contract_idx
ON contract_files (contract_id, created_at DESC);

CREATE INDEX IF NOT EXISTS contract_files_milestone_idx
ON contract_files (milestone_id);

CREATE INDEX IF NOT EXISTS contract_files_storage_key_idx
ON contract_files (storage_key);

-- Historical versions of each file. New versions are appended; older rows
-- are never mutated so historical uploads remain auditable.
CREATE TABLE IF NOT EXISTS contract_file_versions (
id UEID PRIMARY KEY DEFAULT genrand_uuid(),
file_id UUID NOT NULL REFERENCES contract_files (id) ON DELETE CASCADE,
version INTEGER NOT NULL,
file_name TEXT NOT NULL,
mime_type TEXT NOT NULL,
file_size BIGINT NOT NULL,
storage_key TEXT NOT NULL,
checksum TEXT NOT NULL,
uploaded_by UUID NOT NULL REFERENCES users (id),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT contract_file_versions_version_check CHECK (version >= 1),
CONSTRAINT contract_file_versions_size_check CHECK (file_size > 0),
UNIQUE (
file_id, version)
);

CREATE INDEX IF NOT EXISTS contract_file_versions_file_idx
ON contract_file_versions (file_id, version DESC);

-- Audit trail for every file operation. Rows are append-only and survive
-- file deletion so historical references are preserved.
CREATE TABLE IF NOT EXISTS contract_file_audit_logs (
id UEID PRIMARY KEY DEFAULT genrand_uuid(),
file_id UEID NULL,
contract_id UEID NOT NULL,
milestone_id UEID,
actor_id UEID NOT NULL,
action TEXT NOT NULL,
version INTEGER,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
CONSTRAINT contract_file_audit_logs_action_check
CHECK (action IN ('upload', 'version', 'download', 'delete'))
);

CREATE INDEX IF NOT EXISTS contract_file_audit_logs_file_idx
ON contract_file_audit_logs (file_id, created_at DESC);

CREATE INDEX IF NOT EXISTS contract_file_audit_logs_contract_idx
ON contract_file_audit_logs (contract_id, created_at DESC);
Loading