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
1 change: 1 addition & 0 deletions api.js

Large diffs are not rendered by default.

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 }
)
}
})
})
18 changes: 18 additions & 0 deletions branch_structure.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"default_branch": "main",
"branches": [
"main",
"origin",
"security/issue-244-project-workspace-file-metadata-api"
],
"parent": {
"main": null,
"origin": "main",
"security/issue-244-project-workspace-file-metadata-api": "main"
},
"order": [
"main",
"origin",
"security/issue-244-project-workspace-file-metadata-api"
]
}
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
}
Loading