diff --git a/app/api/contracts/[id]/route.ts b/app/api/contracts/[id]/route.ts index c4c1fab..b0e86a6 100644 --- a/app/api/contracts/[id]/route.ts +++ b/app/api/contracts/[id]/route.ts @@ -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 { @@ -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 } + ) } -}) \ No newline at end of file +}) diff --git a/lib/activity/service.ts b/lib/activity/service.ts index e13aa11..440d7b6 100644 --- a/lib/activity/service.ts +++ b/lib/activity/service.ts @@ -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 ?? {}, @@ -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 * ` @@ -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) @@ -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` @@ -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} @@ -162,4 +172,4 @@ export class ActivityService { } } -export const activityService = new ActivityService() \ No newline at end of file +export const activityService = new ActivityService() diff --git a/lib/contract-sync/service.ts b/lib/contract-sync/service.ts index 4ea8a94..d3b62cc 100644 --- a/lib/contract-sync/service.ts +++ b/lib/contract-sync/service.ts @@ -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) => { @@ -120,7 +120,7 @@ export class ContractSyncService { WHERE dedupe_key = ${dedupeKey} AND status = 'success' LIMIT 1 - `) as unknown[] + `) as unknoww[] return rows.length > 0 } @@ -180,7 +180,7 @@ export class ContractSyncService { private async resolveContractId(contractAddress: string): Promise { 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 } @@ -188,7 +188,7 @@ export class ContractSyncService { private async resolveMilestoneDbId(contractId: string, onChainMilestoneId: number): Promise { 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 diff --git a/lib/contract-sync/types.ts b/lib/contract-sync/types.ts index 1c73c28..aa6abd4 100644 --- a/lib/contract-sync/types.ts +++ b/lib/contract-sync/types.ts @@ -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 = @@ -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 +} + +/** 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 +} diff --git a/lib/db/migrations/011_contract_files.sql b/lib/db/migrations/011_contract_files.sql new file mode 100644 index 0000000..aa7d5cd --- /dev/null +++ b/lib/db/migrations/011_contract_files.sql @@ -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);