diff --git a/app/api/milestones/[id]/route.ts b/app/api/milestones/[id]/route.ts index 890a7a8..37e67bf 100644 --- a/app/api/milestones/[id]/route.ts +++ b/app/api/milestones/[id]/route.ts @@ -3,19 +3,35 @@ export const dynamic = 'force-dynamic' 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' -// Only the project client can update a milestone (and only when not yet submitted/approved/paid) export const PATCH = withAuth(async (request: NextRequest, auth) => { const id = request.nextUrl.pathname.split('/').at(-1) + let body: unknown try { - const body = await request.json() - const { title, description, amount, currency, due_date, sort_order, deliverables } = body + body = await request.json() + } catch { + return NextResponse.json( + { error: 'Request body must be valid JSON', code: 'INVALID_JSON' }, + { status: 400 } + ) + } + + const parsed = UpdateMilestoneSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', code: 'INVALID_REQUEST_BODY', details: parsed.error.flatten().fieldErrors }, + { status: 422 } + ) + } + + const { title, description, amount, currency, due_date, sort_order, deliverables } = parsed.data + try { const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1` if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 }) - // Fetch milestone and verify ownership via project const [milestone] = await sql` SELECT m.*, p.client_id FROM milestones m @@ -28,8 +44,7 @@ export const PATCH = withAuth(async (request: NextRequest, auth) => { return NextResponse.json({ error: 'Access denied', code: 'FORBIDDEN' }, { status: 403 }) } - const immutableStatuses = ['submitted', 'approved', 'paid'] - if (immutableStatuses.includes(milestone.status)) { + if ((IMMUTABLE_MILESTONE_STATUS_VALUES as readonly string[]).includes(milestone.status)) { return NextResponse.json( { error: `Cannot update a milestone with status '${milestone.status}'`, code: 'INVALID_STATUS' }, { status: 422 } @@ -42,7 +57,7 @@ export const PATCH = withAuth(async (request: NextRequest, auth) => { description = COALESCE(${description ?? null}, description), amount = COALESCE(${amount ?? null}, amount), currency = COALESCE(${currency ?? null}, currency), - due_date = COALESCE(${due_date ?? null}, due_date), + due_date = COALESCE(${due_date ? due_date.toISOString() : null}, due_date), sort_order = COALESCE(${sort_order ?? null}, sort_order), deliverables = COALESCE(${deliverables ? JSON.stringify(deliverables) : null}, deliverables), updated_at = NOW() diff --git a/app/api/milestones/route.ts b/app/api/milestones/route.ts index 34086d0..b36bb90 100644 --- a/app/api/milestones/route.ts +++ b/app/api/milestones/route.ts @@ -3,20 +3,30 @@ export const dynamic = 'force-dynamic' import { NextRequest, NextResponse } from 'next/server' import { withAuth } from '@/lib/auth/middleware' import { sql } from '@/lib/db' +import { CreateMilestoneSchema } from '@/lib/validations' export const POST = withAuth(async (request: NextRequest, auth) => { + let body: unknown try { - const body = await request.json() - const { project_id, title, description, amount, currency, due_date, sort_order, deliverables } = body + body = await request.json() + } catch { + return NextResponse.json( + { error: 'Request body must be valid JSON', code: 'INVALID_JSON' }, + { status: 400 } + ) + } - if (!project_id || !title || amount === undefined) { - return NextResponse.json( - { error: 'Missing required fields: project_id, title, amount', code: 'MISSING_FIELDS' }, - { status: 400 } - ) - } + const parsed = CreateMilestoneSchema.safeParse(body) + if (!parsed.success) { + return NextResponse.json( + { error: 'Validation failed', code: 'INVALID_REQUEST_BODY', details: parsed.error.flatten().fieldErrors }, + { status: 422 } + ) + } + + const { project_id, title, description, amount, currency, due_date, sort_order, deliverables } = parsed.data - // Verify caller is the project owner (client) + try { const [user] = await sql`SELECT id FROM users WHERE wallet_address = ${auth.walletAddress} LIMIT 1` if (!user) return NextResponse.json({ error: 'User not found', code: 'USER_NOT_FOUND' }, { status: 404 }) @@ -32,10 +42,10 @@ export const POST = withAuth(async (request: NextRequest, auth) => { ${title}, ${description ?? null}, ${amount}, - ${currency ?? 'USDC'}, - ${due_date ?? null}, - ${sort_order ?? 0}, - ${JSON.stringify(deliverables ?? [])} + ${currency}, + ${due_date ? due_date.toISOString() : null}, + ${sort_order}, + ${JSON.stringify(deliverables)} ) RETURNING * ` diff --git a/components/wallet-connect.tsx b/components/wallet-connect.tsx index 6f71407..35f81b5 100644 --- a/components/wallet-connect.tsx +++ b/components/wallet-connect.tsx @@ -1,373 +1,41 @@ -// "use client"; +"use client"; -// import { useState, useEffect, useCallback } from "react"; -// import { Button } from "@/components/ui/button"; -// import { Wallet, LogOut } from "lucide-react"; -// import { -// isConnected, -// requestAccess, -// getUserInfo, -// } from "@stellar/freighter-api"; -// type WalletState = { -// connected: boolean; -// publicKey: string | null; -// error: string | null; -// }; - -// export function WalletConnect() { -// const [wallet, setWallet] = useState({ -// connected: false, -// publicKey: null, -// error: null, -// }); -// const [loading, setLoading] = useState(false); - -// // Check if Freighter is available and already connected -// const checkConnection = useCallback(async () => { -// try { -// const connected = await isConnected(); -// if (connected) { -// const user = await getUserInfo(); -// setWallet({ -// connected: true, -// publicKey: user.publicKey, -// error: null, -// }); -// } -// } catch {} -// }, []); - -// useEffect(() => { -// checkConnection(); -// }, [checkConnection]); - -// const connect = async () => { -// setLoading(true); -// setWallet((prev) => ({ ...prev, error: null })); - -// try { -// await requestAccess(); -// const user = await getUserInfo(); - -// setWallet({ -// connected: true, -// publicKey: user.publicKey, -// error: null, -// }); -// } catch (err) { -// const message = -// err instanceof Error ? err.message : "Failed to connect wallet"; -// setWallet((prev) => ({ ...prev, error: message })); -// } finally { -// setLoading(false); -// } -// }; - - -// const disconnect = () => { -// localStorage.removeItem("stellar_wallet_address"); -// setWallet({ connected: false, publicKey: null, error: null }); -// }; - -// const truncateKey = (key: string): string => { -// if (key.length <= 10) return key; -// return `${key.slice(0, 5)}...${key.slice(-4)}`; -// }; - -// // Error state -// if (wallet.error) { -// return ( -//
-//

{wallet.error}

-//
-// -// -//
-//
-// ); -// } - -// // Connected state -// if (wallet.connected && wallet.publicKey) { -// return ( -//
-// -// {truncateKey(wallet.publicKey)} -// -// -//
-// ); -// } - -// // Disconnected state -// return ( -// -// ); -// } - -// "use client"; - -// import { useState, useEffect, useCallback } from "react"; -// import { Button } from "@/components/ui/button"; -// import { Wallet, LogOut } from "lucide-react"; -// import { -// isConnected, -// requestAccess, -// getUserInfo, -// } from "@stellar/freighter-api"; - -// type WalletState = { -// connected: boolean; -// publicKey: string | null; -// error: string | null; -// }; - -// export function WalletConnect() { -// const [wallet, setWallet] = useState({ -// connected: false, -// publicKey: null, -// error: null, -// }); -// const [loading, setLoading] = useState(false); - -// // Check connection on load -// const checkConnection = useCallback(async () => { -// try { -// const connected = await isConnected(); - -// if (connected) { -// const user = await getUserInfo(); -// setWallet({ -// connected: true, -// publicKey: user.publicKey, -// error: null, -// }); -// } -// } catch { -// // silent fail (user not connected yet) -// } -// }, []); - -// useEffect(() => { -// checkConnection(); -// }, [checkConnection]); - -// const connect = async () => { -// setLoading(true); -// setWallet((prev) => ({ ...prev, error: null })); - -// try { -// await requestAccess(); -// const user = await getUserInfo(); - -// setWallet({ -// connected: true, -// publicKey: user.publicKey, -// error: null, -// }); -// } catch (err) { -// const message = -// err instanceof Error ? err.message : "Failed to connect wallet"; - -// setWallet((prev) => ({ -// ...prev, -// error: message, -// })); -// } finally { -// setLoading(false); -// } -// }; - -// const disconnect = () => { -// setWallet({ -// connected: false, -// publicKey: null, -// error: null, -// }); -// }; - -// const truncateKey = (key: string) => { -// return `${key.slice(0, 5)}...${key.slice(-4)}`; -// }; - -// // πŸ”΄ Error State -// if (wallet.error) { -// return ( -//
-//

-// {wallet.error} -//

-//
-// -// -//
-//
-// ); -// } - -// // 🟒 Connected State -// if (wallet.connected && wallet.publicKey) { -// return ( -//
-// -// {truncateKey(wallet.publicKey)} -// -// -//
-// ); -// } - -// // βšͺ Default (Disconnected) -// return ( -// -// ); -// }"use client"; - -import { useState, useEffect, useCallback } from "react"; import { Button } from "@/components/ui/button"; import { Wallet, LogOut } from "lucide-react"; -import { - isConnected, - requestAccess, - getAddress, -} from "@stellar/freighter-api"; - -type WalletState = { - connected: boolean; - publicKey: string | null; - error: string | null; -}; +import { useFreighter, truncateStellarAddress } from "@/lib/hooks/use-freighter"; export function WalletConnect() { - const [wallet, setWallet] = useState({ - connected: false, - publicKey: null, - error: null, - }); - const [loading, setLoading] = useState(false); - - const checkConnection = useCallback(async () => { - try { - const connected = await isConnected(); - - if (connected) { - const { address, error } = await getAddress(); - - if (error || !address) return; - - setWallet({ - connected: true, - publicKey: address, - error: null, - }); - } - } catch { - // ignore - } - }, []); - - useEffect(() => { - checkConnection(); - }, [checkConnection]); - - const connect = async () => { - setLoading(true); - setWallet((prev) => ({ ...prev, error: null })); - - try { - await requestAccess(); - - const { address, error } = await getAddress(); - - if (error || !address) { - throw new Error(error || "Failed to retrieve wallet address"); - } - - setWallet({ - connected: true, - publicKey: address, - error: null, - }); - } catch (err) { - const message = - err instanceof Error ? err.message : "Failed to connect wallet"; - - setWallet((prev) => ({ - ...prev, - error: message, - })); - } finally { - setLoading(false); - } - }; - - const disconnect = () => { - setWallet({ - connected: false, - publicKey: null, - error: null, - }); - }; - - const truncateKey = (key: string) => - `${key.slice(0, 5)}...${key.slice(-4)}`; + const { + address, + isConnected, + isConnecting, + isInitializing, + isWrongNetwork, + network, + error, + connect, + disconnect, + clearError, + } = useFreighter(); + + if (isInitializing) { + return ( + + ); + } - if (wallet.error) { + if (error) { return (
-

- {wallet.error} -

+

{error}

- -
@@ -375,18 +43,16 @@ export function WalletConnect() { ); } - if (wallet.connected && wallet.publicKey) { + if (isConnected && address) { return (
+ {isWrongNetwork && ( + Wrong network ({network}) + )} - {truncateKey(wallet.publicKey)} + {truncateStellarAddress(address)} -
@@ -394,9 +60,9 @@ export function WalletConnect() { } return ( - ); -} \ No newline at end of file +} diff --git a/lib/hooks/use-freighter.ts b/lib/hooks/use-freighter.ts new file mode 100644 index 0000000..b2b9f07 --- /dev/null +++ b/lib/hooks/use-freighter.ts @@ -0,0 +1,11 @@ +'use client' + +export { + useStellarWallet as useFreighter, + truncateStellarAddress, + networkLabel, + StellarWalletProvider, + REQUIRED_NETWORK, +} from '@/components/wallet-provider' + +export type { StellarNetwork } from '@/components/wallet-provider' diff --git a/lib/validations/index.ts b/lib/validations/index.ts new file mode 100644 index 0000000..e4823cb --- /dev/null +++ b/lib/validations/index.ts @@ -0,0 +1,150 @@ +import { z } from 'zod' + +// ─── Wallet ──────────────────────────────────────────────────────────────── + +const STELLAR_ADDRESS_REGEX = /^G[A-Z2-7]{55}$/ + +export const WalletAddressSchema = z + .string() + .trim() + .regex(STELLAR_ADDRESS_REGEX, 'Invalid Stellar wallet address') + +export const WalletSignatureSchema = z.object({ + walletAddress: WalletAddressSchema, + signature: z.string().trim().min(1).max(4096, 'Signature too long'), + message: z.string().trim().min(1).max(512, 'Message too long'), +}) + +export const WalletTransactionSchema = z.object({ + walletAddress: WalletAddressSchema, + txHash: z.string().trim().min(1).max(256, 'Transaction hash too long'), + amount: z.number().positive('Amount must be positive'), + currency: z.enum(['XLM', 'USDC']).default('USDC'), +}) + +// ─── User ────────────────────────────────────────────────────────────────── + +export const UserProfileSchema = z.object({ + name: z + .string() + .trim() + .min(1, 'Name is required') + .max(100, 'Name must be 100 characters or fewer'), + bio: z + .string() + .trim() + .max(500, 'Bio must be 500 characters or fewer') + .optional(), + skills: z + .array(z.string().trim().min(1)) + .max(20, 'You can add at most 20 skills') + .optional(), + hourlyRate: z + .number() + .nonnegative('Hourly rate cannot be negative') + .optional(), + walletAddress: WalletAddressSchema.optional(), +}) + +export const UserRoleSchema = z.enum(['client', 'freelancer', 'admin']) + +export const UserAuthInputSchema = z.object({ + walletAddress: WalletAddressSchema, + nonce: z.string().trim().min(1).max(256), + signature: z.string().trim().min(1).max(4096), + message: z.string().trim().min(1).max(512).optional(), +}) + +// ─── Project ─────────────────────────────────────────────────────────────── + +const PROJECT_STATUSES = ['open', 'in_progress', 'completed', 'cancelled'] as const + +export const ProjectStatusSchema = z.enum(PROJECT_STATUSES) + +export const CreateProjectSchema = z.object({ + clientId: z + .string({ required_error: 'clientId is required' }) + .uuid('clientId must be a valid UUID'), + title: z + .string({ required_error: 'title is required' }) + .min(1, 'title cannot be empty') + .max(200, 'title must be 200 characters or fewer'), + description: z + .string() + .max(2000, 'description must be 2000 characters or fewer') + .optional(), + budgetUsdc: z + .number({ required_error: 'budgetUsdc is required' }) + .positive('budgetUsdc must be a positive number') + .multipleOf(0.0000001, 'budgetUsdc supports up to 7 decimal places'), + deadline: z.coerce + .date() + .min(new Date(), 'deadline must be in the future') + .optional(), + milestoneCount: z + .number() + .int('milestoneCount must be an integer') + .min(0, 'milestoneCount cannot be negative') + .optional(), + freelancerId: z.string().uuid('freelancerId must be a valid UUID').optional(), +}) + +export const ListProjectsSchema = z.object({ + clientId: z.string().uuid('clientId must be a valid UUID').optional(), + status: ProjectStatusSchema.optional(), + limit: z.coerce.number().int().min(1).max(100).optional(), + offset: z.coerce.number().int().min(0).optional(), +}) + +// ─── Milestone ───────────────────────────────────────────────────────────── + +const MILESTONE_STATUSES = ['pending', 'in_progress', 'submitted', 'approved', 'paid', 'disputed'] as const +const IMMUTABLE_MILESTONE_STATUSES = ['submitted', 'approved', 'paid'] as const + +export const MilestoneStatusSchema = z.enum(MILESTONE_STATUSES) + +export const IMMUTABLE_MILESTONE_STATUS_VALUES = IMMUTABLE_MILESTONE_STATUSES + +export const CreateMilestoneSchema = z.object({ + project_id: z + .string({ required_error: 'project_id is required' }) + .uuid('project_id must be a valid UUID'), + title: z + .string({ required_error: 'title is required' }) + .min(1, 'title cannot be empty') + .max(200, 'title must be 200 characters or fewer'), + description: z + .string() + .max(2000, 'description must be 2000 characters or fewer') + .optional(), + amount: z + .number({ required_error: 'amount is required' }) + .positive('amount must be positive'), + currency: z.enum(['XLM', 'USDC']).default('USDC'), + due_date: z.coerce.date().optional(), + sort_order: z.number().int().min(0).optional().default(0), + deliverables: z.array(z.string().trim().min(1)).optional().default([]), +}) + +export const UpdateMilestoneSchema = z.object({ + title: z + .string() + .min(1, 'title cannot be empty') + .max(200, 'title must be 200 characters or fewer') + .optional(), + description: z + .string() + .max(2000, 'description must be 2000 characters or fewer') + .optional(), + amount: z.number().positive('amount must be positive').optional(), + currency: z.enum(['XLM', 'USDC']).optional(), + due_date: z.coerce.date().optional(), + sort_order: z.number().int().min(0).optional(), + deliverables: z.array(z.string().trim().min(1)).optional(), +}) + +export type CreateMilestoneInput = z.infer +export type UpdateMilestoneInput = z.infer +export type CreateProjectInput = z.infer +export type UserProfileInput = z.infer +export type WalletSignatureInput = z.infer