From 9ba6c9220264c141caa0d19fe59bf7d378c9eb9a Mon Sep 17 00:00:00 2001 From: shaiksohelll Date: Mon, 18 May 2026 23:31:43 +0530 Subject: [PATCH 01/14] feat(wallet): worker withdrawal flow + auto-release cron --- src/app/_actions/wallet.ts | 65 +++++- src/components/features/topup-dialog.tsx | 3 +- src/components/features/wallet-view.tsx | 26 +-- src/components/features/withdraw-dialog.tsx | 167 +++++++++++++++ src/lib/schemas/wallet.ts | 8 + src/lib/uuid.ts | 31 +++ .../auto_release_milestones/index.ts | 10 - .../20260518135600_withdraw_wallet.sql | 96 +++++++++ ...0_withdraw_wallet_fix_ambiguous_column.sql | 86 ++++++++ ...60518225500_schedule_auto_release_cron.sql | 192 ++++++++++++++++++ 10 files changed, 660 insertions(+), 24 deletions(-) create mode 100644 src/components/features/withdraw-dialog.tsx create mode 100644 src/lib/uuid.ts delete mode 100644 supabase/functions/auto_release_milestones/index.ts create mode 100644 supabase/migrations/20260518135600_withdraw_wallet.sql create mode 100644 supabase/migrations/20260518181500_withdraw_wallet_fix_ambiguous_column.sql create mode 100644 supabase/migrations/20260518225500_schedule_auto_release_cron.sql diff --git a/src/app/_actions/wallet.ts b/src/app/_actions/wallet.ts index 9c38078..2f13db0 100644 --- a/src/app/_actions/wallet.ts +++ b/src/app/_actions/wallet.ts @@ -2,7 +2,12 @@ import { revalidatePath } from "next/cache"; import { createClient } from "@/lib/supabase/server"; -import { topupWalletSchema, type TopupWalletInput } from "@/lib/schemas/wallet"; +import { + topupWalletSchema, + type TopupWalletInput, + withdrawWalletSchema, + type WithdrawWalletInput, +} from "@/lib/schemas/wallet"; import type { ActionResult } from "./escrow"; // ── Helper ──────────────────────────────────────────────────────────────────── @@ -67,3 +72,61 @@ export async function topupWalletAction( return { success: false, error: msg }; } } + +// ── Withdraw Wallet ─────────────────────────────────────────────────────────── +// wallet → external: calls withdraw_wallet() SECURITY DEFINER function (test mode) +export async function withdrawWalletAction( + raw: WithdrawWalletInput, +): Promise> { + try { + const parsed = withdrawWalletSchema.safeParse(raw); + if (!parsed.success) { + return { success: false, error: parsed.error.issues[0]?.message ?? "Invalid input" }; + } + + const { supabase } = await getAuthUserId(); + const { amount, idempotency_key } = parsed.data; + + const { data, error } = await supabase.rpc("withdraw_wallet", { + p_amount: amount, + p_idempotency_key: idempotency_key, + }); + + if (error) { + const msg = error.message.toLowerCase(); + if (msg.includes("invalid_amount")) { + return { success: false, error: "Amount must be at least ₹100." }; + } + if (msg.includes("insufficient_balance")) { + return { success: false, error: "Not enough balance in your wallet." }; + } + if (msg.includes("not_authenticated")) { + return { success: false, error: "You need to be signed in." }; + } + if (msg.includes("wallet_not_found")) { + return { success: false, error: "Wallet not found. Please contact support." }; + } + return { success: false, error: "Could not withdraw from wallet. Please try again." }; + } + + const row = Array.isArray(data) ? data[0] : data; + if (!row) { + return { success: false, error: "Could not withdraw from wallet. Please try again." }; + } + + revalidatePath("/client/wallet", "layout"); + revalidatePath("/worker/wallet", "layout"); + + return { + success: true, + data: { + availableBalance: Number(row.available_balance), + ledgerId: row.ledger_id as string, + }, + }; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + // TODO: Sentry.captureException(err); + return { success: false, error: msg }; + } +} \ No newline at end of file diff --git a/src/components/features/topup-dialog.tsx b/src/components/features/topup-dialog.tsx index 896e81b..c37453c 100644 --- a/src/components/features/topup-dialog.tsx +++ b/src/components/features/topup-dialog.tsx @@ -1,5 +1,6 @@ "use client"; +import { generateUuid } from "@/lib/uuid"; import { useEffect, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Plus } from "lucide-react"; @@ -41,7 +42,7 @@ export function TopUpDialog() { mutationFn: async (amount: number) => { const result = await topupWalletAction({ amount, - idempotency_key: crypto.randomUUID(), + idempotency_key: generateUuid(), }); if (!result.success) { throw new Error(result.error); diff --git a/src/components/features/wallet-view.tsx b/src/components/features/wallet-view.tsx index 65f6229..c91f008 100644 --- a/src/components/features/wallet-view.tsx +++ b/src/components/features/wallet-view.tsx @@ -8,6 +8,7 @@ import { StatusBadge } from "@/components/ui/status-badge"; import { Skeleton } from "@/components/ui/skeleton"; import { Separator } from "@/components/ui/separator"; import { TopUpDialog } from "@/components/features/topup-dialog"; +import { WithdrawDialog } from "@/components/features/withdraw-dialog"; import { formatInr, relativeTime } from "@/lib/format"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -163,8 +164,11 @@ export function WalletView({ role }: { role: "client" | "worker" }) { -
- +
+ {role === "client" && } + {role === "worker" && ( + + )}
{/* ── Locked breakdown (client only) ── */} @@ -217,13 +221,12 @@ export function WalletView({ role }: { role: "client" | "worker" }) { >
{isIncoming ? ( @@ -260,9 +263,8 @@ export function WalletView({ role }: { role: "client" | "worker" }) {
{isIncoming ? "+" : "-"} {formatInr(entry.amount)} diff --git a/src/components/features/withdraw-dialog.tsx b/src/components/features/withdraw-dialog.tsx new file mode 100644 index 0000000..6681a11 --- /dev/null +++ b/src/components/features/withdraw-dialog.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { generateUuid } from "@/lib/uuid"; +import { useEffect, useRef, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { ArrowDownToLine } from "lucide-react"; +import { toast } from "sonner"; + +import { withdrawWalletAction } from "@/app/_actions/wallet"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { formatInr } from "@/lib/format"; + +const QUICK_AMOUNTS = [500, 1000, 5000, 10000]; + +const blurOnWheel = (e: React.WheelEvent) => { + (e.target as HTMLInputElement).blur(); +}; + +export function WithdrawDialog({ availableBalance }: { availableBalance: number }) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [amountStr, setAmountStr] = useState(""); + const inFlightRef = useRef(false); + const mountedRef = useRef(true); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + const mutation = useMutation({ + mutationFn: async (amount: number) => { + const result = await withdrawWalletAction({ + amount, + idempotency_key: generateUuid(), + }); + if (!result.success) { + throw new Error(result.error); + } + return { amount, newBalance: result.data?.availableBalance ?? 0 }; + }, + onSuccess: ({ amount, newBalance }) => { + // Always invalidate so the wallet view re-fetches even if we unmounted. + queryClient.invalidateQueries({ queryKey: ["wallet"] }); + if (!mountedRef.current) return; + toast.success(`Withdrew ${formatInr(amount)}. New balance: ${formatInr(newBalance)}`); + setAmountStr(""); + setOpen(false); + }, + onError: (err: Error) => { + if (!mountedRef.current) return; + toast.error(err.message || "Could not withdraw from wallet. Please try again."); + }, + onSettled: () => { + inFlightRef.current = false; + }, + }); + + const isPending = mutation.isPending; + const amount = Number(amountStr); + const isValidAmount = + Number.isFinite(amount) && amount >= 100 && amount <= availableBalance; + const canWithdraw = availableBalance >= 100; + + const handleSubmit = () => { + if (inFlightRef.current || isPending) return; + if (!Number.isFinite(amount) || amount < 100) { + toast.error("Amount must be at least ₹100."); + return; + } + if (amount > availableBalance) { + toast.error("Amount exceeds available balance."); + return; + } + inFlightRef.current = true; + mutation.mutate(amount); + }; + + return ( + <> + + + !isPending && setOpen(o)}> + + + Withdraw from wallet + + Available balance: {formatInr(availableBalance)}. Minimum withdrawal{" "} + {formatInr(100)}. + + + +
+
+ Amount + setAmountStr(e.target.value)} + onWheel={blurOnWheel} + disabled={isPending} + /> +
+ +
+ {QUICK_AMOUNTS.map((amt) => ( + + ))} + +
+
+ + + + + +
+
+ + ); +} \ No newline at end of file diff --git a/src/lib/schemas/wallet.ts b/src/lib/schemas/wallet.ts index 7ea0ba2..e719f25 100644 --- a/src/lib/schemas/wallet.ts +++ b/src/lib/schemas/wallet.ts @@ -9,3 +9,11 @@ export const topupWalletSchema = z.object({ }); export type TopupWalletInput = z.infer; +export const withdrawWalletSchema = z.object({ + amount: z + .number({ invalid_type_error: "Amount must be a number." }) + .min(100, "Minimum withdrawal is ₹100."), + idempotency_key: z.string().uuid(), +}); + +export type WithdrawWalletInput = z.infer; \ No newline at end of file diff --git a/src/lib/uuid.ts b/src/lib/uuid.ts new file mode 100644 index 0000000..c902a0d --- /dev/null +++ b/src/lib/uuid.ts @@ -0,0 +1,31 @@ +/** + * Generate a UUID v4. + * + * Uses `crypto.randomUUID()` in secure contexts (HTTPS or localhost). + * Falls back to `crypto.getRandomValues()` for insecure dev contexts + * (e.g. accessing dev server via LAN IP `192.168.x.x` over HTTP). + * + * Both paths produce RFC4122 v4 UUIDs with cryptographic randomness — safe + * for idempotency keys, request IDs, etc. + */ +export function generateUuid(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + if (typeof crypto !== "undefined" && typeof crypto.getRandomValues === "function") { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 + const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; + } + + // Last-ditch fallback — should never hit in any modern browser. + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { + const r = Math.floor(Math.random() * 16); + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} \ No newline at end of file diff --git a/supabase/functions/auto_release_milestones/index.ts b/supabase/functions/auto_release_milestones/index.ts deleted file mode 100644 index 292263a..0000000 --- a/supabase/functions/auto_release_milestones/index.ts +++ /dev/null @@ -1,10 +0,0 @@ -// Phase 1 skeleton: Auto-release milestones (Edge Function) -// This is a placeholder scaffold. The actual logic will be implemented in a later phase. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function handler(_event: any) { - // Event payloads from Supabase cron or HTTP trigger can be handled here. - return { - status: 'ok', - message: 'auto_release_milestones scaffold ready', - }; -} diff --git a/supabase/migrations/20260518135600_withdraw_wallet.sql b/supabase/migrations/20260518135600_withdraw_wallet.sql new file mode 100644 index 0000000..53e8826 --- /dev/null +++ b/supabase/migrations/20260518135600_withdraw_wallet.sql @@ -0,0 +1,96 @@ +-- Migration: withdraw_wallet RPC +-- Purpose: Worker-initiated wallet withdrawal (test mode — no real payout). +-- Mirrors topup_wallet: look-then-leap idempotency, wallet-update-then-ledger. +-- Bounds: 100 <= amount <= wallet.available_balance. +-- Adds per-user UNIQUE index from day one (lesson from topup hardening 20260518002100). + +create or replace function public.withdraw_wallet( + p_amount numeric, + p_idempotency_key uuid +) +returns table ( + available_balance numeric, + ledger_id uuid +) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_profile_id uuid; + v_min_amount constant numeric := 100; + v_current_balance numeric; + v_ledger_id uuid; + v_new_balance numeric; +begin + -- 1. Auth check + v_profile_id := auth.uid(); + if v_profile_id is null then + raise exception 'not_authenticated' using errcode = '28000'; + end if; + + -- 2. Validate amount (minimum ₹100; upper bound enforced against balance below) + if p_amount is null or p_amount < v_min_amount then + raise exception 'invalid_amount' using errcode = '22023'; + end if; + + -- 3. Idempotency replay: if a withdraw with this key already exists for THIS user, + -- return current balance + existing ledger id without re-debiting. + select id into v_ledger_id + from escrow_ledger + where reference_id = p_idempotency_key + and type = 'withdraw' + and from_wallet = v_profile_id + limit 1; + + if v_ledger_id is not null then + select w.available_balance into v_new_balance + from wallets w + where w.profile_id = v_profile_id; + return query select v_new_balance, v_ledger_id; + return; + end if; + + -- 4. Read + lock wallet row to serialize concurrent withdraws and prevent overdraft race. + select available_balance into v_current_balance + from wallets + where profile_id = v_profile_id + for update; + + if v_current_balance is null then + raise exception 'wallet_not_found' using errcode = '22023'; + end if; + + if p_amount > v_current_balance then + raise exception 'insufficient_balance' using errcode = '22023'; + end if; + + -- 5. Debit the wallet and capture the new balance + update wallets + set available_balance = available_balance - p_amount + where profile_id = v_profile_id + returning available_balance into v_new_balance; + + -- 6. Audit row in escrow_ledger + -- job_id / milestone_id / to_wallet all null (wallet → external) + insert into escrow_ledger ( + job_id, milestone_id, from_wallet, to_wallet, amount, type, reference_id + ) + values ( + null, null, v_profile_id, null, p_amount, 'withdraw', p_idempotency_key + ) + returning id into v_ledger_id; + + return query select v_new_balance, v_ledger_id; +end; +$$; + +-- Grants +revoke all on function public.withdraw_wallet(numeric, uuid) from public; +grant execute on function public.withdraw_wallet(numeric, uuid) to authenticated; + +-- Per-user idempotency: same (from_wallet, reference_id) cannot insert twice for withdraws. +-- Mirrors topup's per-user hardening from 20260518002100; applied from day one for withdraw. +create unique index if not exists escrow_ledger_withdraw_idempotency_idx + on public.escrow_ledger (from_wallet, reference_id) + where type = 'withdraw'; \ No newline at end of file diff --git a/supabase/migrations/20260518181500_withdraw_wallet_fix_ambiguous_column.sql b/supabase/migrations/20260518181500_withdraw_wallet_fix_ambiguous_column.sql new file mode 100644 index 0000000..93d134c --- /dev/null +++ b/supabase/migrations/20260518181500_withdraw_wallet_fix_ambiguous_column.sql @@ -0,0 +1,86 @@ +-- Fix: column reference "available_balance" was ambiguous inside withdraw_wallet. +-- The function declares RETURNS TABLE (available_balance numeric, ledger_id uuid), +-- which creates an implicit OUT variable that shadows wallets.available_balance. +-- All wallets/escrow_ledger references in the body are now table-aliased. + +create or replace function withdraw_wallet( + p_amount numeric, + p_idempotency_key uuid +) +returns table ( + available_balance numeric, + ledger_id uuid +) +language plpgsql +security definer +set search_path = public +as $$ +declare + v_profile_id uuid; + v_current_balance numeric; + v_new_balance numeric; + v_ledger_id uuid; +begin + -- 1. Auth + v_profile_id := auth.uid(); + if v_profile_id is null then + raise exception 'not_authenticated' using errcode = '28000'; + end if; + + -- 2. Bounds + if p_amount < 100 then + raise exception 'invalid_amount' using errcode = '22023'; + end if; + + -- 3. Idempotency replay (per-user) + select el.id into v_ledger_id + from escrow_ledger el + where el.reference_id = p_idempotency_key + and el.type = 'withdraw' + and el.from_wallet = v_profile_id + limit 1; + + if v_ledger_id is not null then + select w.available_balance into v_new_balance + from wallets w + where w.profile_id = v_profile_id; + + return query select v_new_balance, v_ledger_id; + return; + end if; + + -- 4. Read + lock wallet + select w.available_balance into v_current_balance + from wallets w + where w.profile_id = v_profile_id + for update; + + if v_current_balance is null then + raise exception 'wallet_not_found' using errcode = '22023'; + end if; + + -- 5. Sufficiency check + if p_amount > v_current_balance then + raise exception 'insufficient_balance' using errcode = '22023'; + end if; + + -- 6. Debit + read new balance + update wallets w + set available_balance = w.available_balance - p_amount + where w.profile_id = v_profile_id + returning w.available_balance into v_new_balance; + + -- 7. Ledger entry + insert into escrow_ledger ( + job_id, milestone_id, from_wallet, to_wallet, amount, type, reference_id + ) + values ( + null, null, v_profile_id, null, p_amount, 'withdraw', p_idempotency_key + ) + returning id into v_ledger_id; + + return query select v_new_balance, v_ledger_id; +end; +$$; + +grant execute on function withdraw_wallet(numeric, uuid) to authenticated; \ No newline at end of file diff --git a/supabase/migrations/20260518225500_schedule_auto_release_cron.sql b/supabase/migrations/20260518225500_schedule_auto_release_cron.sql new file mode 100644 index 0000000..59c0503 --- /dev/null +++ b/supabase/migrations/20260518225500_schedule_auto_release_cron.sql @@ -0,0 +1,192 @@ +-- ───────────────────────────────────────────────────────────────────────────── +-- Schedule daily auto-release of milestones past their 72h auto_release_at. +-- ───────────────────────────────────────────────────────────────────────────── +-- +-- Why this migration is more involved than just `cron.schedule(...)`: +-- +-- guard_milestones_status and guard_jobs_status block status changes unless +-- the call is via a "real" SECURITY DEFINER context (current_user ≠ +-- session_user) or the caller is admin. Neither holds when pg_cron triggers +-- auto_release_milestones(): both session_user and current_user are +-- `postgres`, and there's no auth.uid(). +-- +-- Reassigning the function to supabase_admin or service_role is blocked in +-- managed Supabase (postgres lacks SET ROLE on supabase_admin; service_role +-- lacks CREATE on public so can't own objects there). +-- +-- Clean fix: extend both guards with a third permissive condition — a session +-- GUC `pakka.allow_milestone_status_change`. Existing escape hatches (role +-- split + is_admin) are preserved, so submit_milestone, admin_force_release, +-- and any other status-changers continue to work unchanged. Only +-- auto_release_milestones needs the new GUC setter. +-- ───────────────────────────────────────────────────────────────────────────── + +-- 1. Teach guard_milestones_status about the sanctioned-bypass GUC. +create or replace function public.guard_milestones_status() +returns trigger +language plpgsql +security definer +set search_path to 'public' +as $$ +begin + if new.status is distinct from old.status + and current_user is not distinct from session_user + and not coalesce(current_setting('pakka.allow_milestone_status_change', true)::boolean, false) + and not public.is_admin() then + raise exception 'milestones.status can only change via SECURITY DEFINER function or admin'; + end if; + return new; +end; +$$; + +-- 2. Same treatment for guard_jobs_status (uses the same GUC name — +-- auto_release_milestones updates both tables in one transaction). +create or replace function public.guard_jobs_status() +returns trigger +language plpgsql +security definer +set search_path to 'public' +as $$ +begin + if new.status is distinct from old.status + and current_user is not distinct from session_user + and not coalesce(current_setting('pakka.allow_milestone_status_change', true)::boolean, false) + and not public.is_admin() then + raise exception 'jobs.status can only change via SECURITY DEFINER function or admin'; + end if; + return new; +end; +$$; + +-- 3. Add the GUC setter at the top of auto_release_milestones. Body is +-- otherwise identical to the existing implementation. +-- `true` for the is_local arg → setting is transaction-local, auto-resets. +create or replace function public.auto_release_milestones() +returns integer +language plpgsql +security definer +set search_path to 'public' +as $$ +declare + v_milestone record; + v_count integer := 0; +begin + -- Sanction this transaction's status updates for the guard triggers. + perform set_config('pakka.allow_milestone_status_change', 'on', true); + + for v_milestone in + select m.id, m.job_id, j.client_id, j.worker_id, m.amount + from public.milestones m + join public.jobs j on j.id = m.job_id + where m.status = 'submitted'::public.milestone_status + and m.auto_release_at is not null + and m.auto_release_at < now() + and j.worker_id is not null + and not exists ( + select 1 + from public.disputes d + where d.milestone_id = m.id + and d.status in ('open', 'mediating') + ) + order by m.auto_release_at + for update of m skip locked + loop + -- Lock wallets in consistent order to avoid deadlocks. + perform 1 + from public.wallets w + where w.profile_id in (v_milestone.client_id, v_milestone.worker_id) + order by w.profile_id + for update; + + if exists ( + select 1 + from public.wallets w + where w.profile_id = v_milestone.client_id + and w.locked_balance >= v_milestone.amount + ) then + update public.wallets + set locked_balance = locked_balance - v_milestone.amount + where profile_id = v_milestone.client_id; + + update public.wallets + set available_balance = available_balance + v_milestone.amount + where profile_id = v_milestone.worker_id; + + update public.milestones + set status = 'released'::public.milestone_status, + approved_at = now() + where id = v_milestone.id; + + insert into public.escrow_ledger ( + job_id, milestone_id, from_wallet, to_wallet, amount, type, reference_id + ) values ( + v_milestone.job_id, + v_milestone.id, + v_milestone.client_id, + v_milestone.worker_id, + v_milestone.amount, + 'release'::public.ledger_type, + v_milestone.id + ); + + if not exists ( + select 1 + from public.milestones m2 + where m2.job_id = v_milestone.job_id + and m2.status not in ('released', 'refunded') + ) then + update public.jobs + set status = 'completed'::public.job_status + where id = v_milestone.job_id; + end if; + + insert into public.notifications (recipient_id, type, title, body, data) + values ( + v_milestone.worker_id, + 'milestone_auto_released', + 'Milestone Auto-Released', + 'Your milestone payment has been automatically released.', + jsonb_build_object( + 'job_id', v_milestone.job_id, + 'milestone_id', v_milestone.id, + 'amount', v_milestone.amount + ) + ); + + insert into public.notifications (recipient_id, type, title, body, data) + values ( + v_milestone.client_id, + 'milestone_auto_released', + 'Milestone Auto-Released', + 'A milestone payment was automatically released after 72 hours.', + jsonb_build_object( + 'job_id', v_milestone.job_id, + 'milestone_id', v_milestone.id, + 'amount', v_milestone.amount + ) + ); + + v_count := v_count + 1; + end if; + end loop; + + return v_count; +end; +$$; + +-- 4. Enable pg_cron (no-op if already enabled). +create extension if not exists pg_cron with schema extensions; + +-- 5. Schedule daily at 20:30 UTC (= 02:00 IST). Idempotent on re-run. +do $$ +begin + if exists (select 1 from cron.job where jobname = 'auto-release-milestones-daily') then + perform cron.unschedule('auto-release-milestones-daily'); + end if; + + perform cron.schedule( + 'auto-release-milestones-daily', + '30 20 * * *', + $cmd$ select public.auto_release_milestones(); $cmd$ + ); +end $$; \ No newline at end of file From 60ad8dae56777211f2ec5477c0e7ad7bedf85812 Mon Sep 17 00:00:00 2001 From: shaiksohelll Date: Thu, 21 May 2026 15:13:51 +0530 Subject: [PATCH 02/14] fix(wallet): address PR #19 review feedback - withdraw_wallet: add is_worker() check (A), p_amount NULL guard (F), p_idempotency_key NULL guard (C), insert-first/catch unique_violation pattern mirroring topup (D), public. schema qualifier (I), w. table alias to disambiguate available_balance vs RETURNS TABLE column - worker-form: replace as any cast with typed cast to fix pre-existing lint error surfaced during PR #19 pipeline - guard_milestones_status + guard_jobs_status: replace GUC bypass with direct session_user='postgres' check (E) - auto_release_milestones: drop set_config GUC line (E), add RAISE NOTICE on insufficient-locked-balance skip (H) - wallet.ts: mapWalletRpcError helper routing by error.code + exact RAISE EXCEPTION token (G), defense-in-depth is_worker() pre-check in withdrawWalletAction (A), raw error logging before generic fallback - topup-dialog + withdraw-dialog: stable idempotencyKey via useState initializer + useEffect reset on close (B), normalize 4-space to 2-space indent on withdraw-dialog (Copilot #9) --- AGENTS.md | 10 +- CLAUDE.md | 4 +- README.md | 16 +- adr/0001-auth.md | 5 +- adr/0002-escrow.md | 5 +- adr/0003-dispute.md | 5 +- adr/0004-auto-release.md | 5 +- adr/0005-realtime-contract.md | 5 +- adr/0006-nav-shell.md | 5 +- adr/0007-rpc-pattern.md | 5 +- adr/template.md | 3 + ...nd-security-definer-as-only-escrow-path.md | 3 +- docs/adr/0003-auth-state-hygiene.md | 2 +- docs/adr/0004-migration-discipline.md | 2 +- docs/data-model.md | 5 + docs/state-machine.md | 36 +- scripts/seed-demo-users.ts | 309 ++++++++++++++--- src/app/_actions/jobs.ts | 30 +- src/app/_actions/wallet.ts | 106 ++++-- src/app/client/account/page.tsx | 220 ++++++------- .../client/jobs/[id]/client-job-detail.tsx | 141 ++++---- src/app/client/jobs/[id]/fund/page.tsx | 6 +- .../[id]/milestones/client-milestones.tsx | 164 ++++----- src/app/client/jobs/client-job-list.tsx | 31 +- src/app/client/jobs/new/post-job-form.tsx | 79 +++-- src/app/client/layout.tsx | 6 +- src/app/login/actions.ts | 5 - src/app/onboarding/actions.ts | 22 +- src/app/onboarding/worker/worker-form.tsx | 84 +++-- src/app/page.tsx | 2 +- src/app/worker/account/page.tsx | 310 +++++++++--------- .../applications/worker-applications.tsx | 28 +- src/app/worker/feed/worker-feed.tsx | 63 ++-- src/app/worker/jobs/[id]/apply-modal.tsx | 18 +- .../[id]/milestones/worker-milestones.tsx | 141 ++++---- .../worker/jobs/[id]/worker-job-detail.tsx | 69 ++-- src/app/worker/layout.tsx | 6 +- .../account/change-phone-dialog.tsx | 282 ++++++++-------- .../account/delete-account-dialog.tsx | 185 +++++------ .../account/edit-profile-dialog.tsx | 176 +++++----- src/components/account/sign-out-button.tsx | 100 +++--- src/components/features/topup-dialog.tsx | 15 +- src/components/features/wallet-view.tsx | 22 +- src/components/features/withdraw-dialog.tsx | 277 ++++++++-------- src/components/nav/client-nav-shell.tsx | 12 +- src/components/nav/worker-nav-shell.tsx | 10 +- src/components/ui/alert-dialog.tsx | 87 ++--- src/components/ui/badge.tsx | 27 +- src/components/ui/dialog.tsx | 82 ++--- src/components/ui/progress.tsx | 43 +-- src/components/ui/separator.tsx | 18 +- src/components/ui/skeleton.tsx | 6 +- src/components/ui/status-badge.tsx | 16 +- src/components/ui/textarea.tsx | 10 +- src/hooks/use-user.ts | 22 +- src/lib/__tests__/escrow.property.test.ts | 8 +- src/lib/escrow-machine.ts | 36 +- src/lib/format.ts | 1 - src/lib/schemas/jobs.ts | 35 +- src/lib/schemas/onboarding.ts | 8 +- src/lib/schemas/wallet.ts | 2 +- src/lib/supabase/client.ts | 3 +- src/lib/supabase/server.ts | 4 +- src/lib/types/database.ts | 62 +++- src/lib/uuid.ts | 36 +- supabase/functions/auto-release/index.ts | 16 +- .../20260521080000_pr19_review_fixes.sql | 267 +++++++++++++++ 67 files changed, 2161 insertions(+), 1663 deletions(-) create mode 100644 supabase/migrations/20260521080000_pr19_review_fixes.sql diff --git a/AGENTS.md b/AGENTS.md index dd8219c..7158cf9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,9 +1,11 @@ # Pakka — Agent context ## Project + Open-source milestone-escrow reference implementation for Indian marketplaces. MIT-licensed, production-grade engineering, mock wallet by design. ## Stack + - Next.js 15 (App Router) + TypeScript strict + Tailwind v4 + shadcn/ui - Supabase (Postgres + Auth + Realtime + Storage + Edge Functions) - TanStack Query (server state) + Zustand (client state) @@ -11,6 +13,7 @@ Open-source milestone-escrow reference implementation for Indian marketplaces. M - pnpm package manager ## Inviolable rules + 1. NEVER mutate wallets/ledger from client code. Only via Postgres SECURITY DEFINER functions. 2. NEVER disable RLS, even temporarily. Every table has it. 3. ALL mutations are Server Actions in `src/app/_actions/`. No direct supabase calls from client components for writes. @@ -18,6 +21,7 @@ Open-source milestone-escrow reference implementation for Indian marketplaces. M 5. Money values formatted with `Intl.NumberFormat('en-IN', { style: 'currency', currency: 'INR', maximumFractionDigits: 0 })`. ## Phase status + - Phase 0 (foundations): done - Phase 1 (schema + RLS + state machine SQL): done - Phase 2 (phone OTP auth + role bifurcation + KYC): done @ commit 2bd5443 @@ -26,6 +30,7 @@ Open-source milestone-escrow reference implementation for Indian marketplaces. M - Phase 5-8: pending ## Key paths + - `supabase/migrations/` — DO NOT MODIFY, only consume - `src/lib/supabase/{client,server,middleware}.ts` — auth-aware Supabase clients - `src/app/onboarding/` — Phase 2 KYC flows @@ -34,6 +39,7 @@ Open-source milestone-escrow reference implementation for Indian marketplaces. M - `middleware.ts` — route protection ## Conventions + - Mobile-first, max-width 640px centered on desktop - Status badges via shared `` component - Loading skeleton + empty state + error boundary on every async page @@ -41,11 +47,13 @@ Open-source milestone-escrow reference implementation for Indian marketplaces. M - Sentry capture inside every Server Action try/catch ## What "done" means for any prompt + - `pnpm typecheck` passes - `pnpm lint` passes - Git commit with conventional message format ## Out of scope reminders + - No real money — Demo Mode banner everywhere - No Razorpay integration (documented in `/docs/production-swap.md` instead) -- No React Native — PWA + TWA wrap only \ No newline at end of file +- No React Native — PWA + TWA wrap only diff --git a/CLAUDE.md b/CLAUDE.md index 3e30fde..5519c0b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,7 +149,7 @@ Branch naming: /-, kebab-case. ## Migrations -- Filename: supabase/migrations/_.sql +- Filename: supabase/migrations/\_.sql - Timestamps in IST — no need to convert - Idempotent where possible: create or replace function, do $$ … exception when duplicate_object then null end $$, add column if not exists - One migration per logical change. Don't bundle unrelated schema work. @@ -182,4 +182,4 @@ Effective when Phase 5.5 lands. - One PR, one focus. No mixing unrelated migrations. - Short, clear, step-by-step. Lead with the diff plan, not philosophy. - No filler. Don't repeat what the diff shows. Don't say "I have made the changes" — show the changes. -- Verify before declaring done. pnpm typecheck && pnpm lint && pnpm test && pnpm build — all four green. \ No newline at end of file +- Verify before declaring done. pnpm typecheck && pnpm lint && pnpm test && pnpm build — all four green. diff --git a/README.md b/README.md index 07c67c5..e60d440 100644 --- a/README.md +++ b/README.md @@ -68,15 +68,15 @@ See [`docs/adr/0001-escrow-state-machine-in-postgres.md`](docs/adr/0001-escrow-s > The live demo runs in Demo Mode. Log in at `/login` with any phone below. OTP is always **123456** — no SMS is sent. -| Role | Phone | Notes | -| --- | --- | --- | -| Client | `+91 98765 00001` | Priya — active jobs, escrow funded | -| Client | `+91 98765 00002` | Rohit — milestone awaiting review | -| Client | `+91 98765 00003` | Anjali — has an open dispute | +| Role | Phone | Notes | +| ------ | ----------------- | ----------------------------------------- | +| Client | `+91 98765 00001` | Priya — active jobs, escrow funded | +| Client | `+91 98765 00002` | Rohit — milestone awaiting review | +| Client | `+91 98765 00003` | Anjali — has an open dispute | | Worker | `+91 98765 00011` | Ravi — gold tier, verified, job completed | -| Worker | `+91 98765 00012` | Suresh — submitted milestone | -| Worker | `+91 98765 00014` | Deepak — KYC pending (browse-only) | -| Admin | `+91 98765 00099` | Demo admin — use `/admin` route | +| Worker | `+91 98765 00012` | Suresh — submitted milestone | +| Worker | `+91 98765 00014` | Deepak — KYC pending (browse-only) | +| Admin | `+91 98765 00099` | Demo admin — use `/admin` route | ## Local development diff --git a/adr/0001-auth.md b/adr/0001-auth.md index 6ba2212..43e93d4 100644 --- a/adr/0001-auth.md +++ b/adr/0001-auth.md @@ -1,8 +1,9 @@ Title: ADR-0001: Auth flow design Status: Proposed Context: + - We need a secure phone OTP authentication flow for India-only onboarding. The flow must integrate with Supabase Auth and rely on server actions for writes. -Decision: + Decision: - Use phone OTP (with optional DEMO_MODE bypass for development). Server-side actions handle OTP send/verify. Role bifurcation happens after onboarding. -Consequences: + Consequences: - Client-side code remains minimal for auth mutations; server actions enforce security. This supports deterministic onboarding and KYC flow in Phase 2. diff --git a/adr/0002-escrow.md b/adr/0002-escrow.md index c4208a7..4c76ed3 100644 --- a/adr/0002-escrow.md +++ b/adr/0002-escrow.md @@ -1,8 +1,9 @@ Title: ADR-0002: Escrow data & money flow model Status: Proposed Context: + - The escrow system is the core of Pakka. We require a robust, auditable model that ensures money moves only via SECURITY DEFINER functions and is zero-sum. -Decision: + Decision: - Describe the data model (tables, relationships) and the money flow (fund, release, refund) with explicit guards and RLS rationale. Outline high-level API surface (RPCs) and their scope. -Consequences: + Consequences: - Enables a single source of truth for money movement and auditability. Guides implementation of functions like fund_escrow, submit_milestone, approve_milestone, dispute_milestone, and auto_release. diff --git a/adr/0003-dispute.md b/adr/0003-dispute.md index cfb4db6..661ac1a 100644 --- a/adr/0003-dispute.md +++ b/adr/0003-dispute.md @@ -1,8 +1,9 @@ Title: ADR-0003: Dispute flow Status: Proposed Context: + - Disputes are a critical path that requires admin intercession and robust audit trails while preserving user trust. -Decision: + Decision: - Outline dispute lifecycle states, integration with the escalation path, and how disputes affect ledger state and notifications. -Consequences: + Consequences: - Establishes a secure, auditable dispute workflow with SECURITY DEFINER calls and proper RBAC for admins. diff --git a/adr/0004-auto-release.md b/adr/0004-auto-release.md index 2c55e84..88b26f0 100644 --- a/adr/0004-auto-release.md +++ b/adr/0004-auto-release.md @@ -1,8 +1,9 @@ Title: ADR-0004: Auto-release cadence Status: Proposed Context: + - Milestones should auto-release after a fixed window if no dispute exists to prevent funds from staying locked indefinitely. -Decision: + Decision: - Implement a recurring edge function cron (every 5 minutes in prod cadence) to release funded milestones past their auto_release_at and with no active disputes. -Consequences: + Consequences: - Automates the release flow, reduces manual intervention, and requires robust idempotency and event notifications. diff --git a/adr/0005-realtime-contract.md b/adr/0005-realtime-contract.md index 94364a9..3f6fad1 100644 --- a/adr/0005-realtime-contract.md +++ b/adr/0005-realtime-contract.md @@ -1,8 +1,9 @@ Title: ADR-0005: Realtime contract boundaries Status: Proposed Context: + - The UI must reflect changes in escrow state in real-time for both clients and workers. -Decision: + Decision: - Define the channel boundaries, data privacy constraints, and RPC wrappers that surface updates via Supabase Realtime with proper token binding. -Consequences: + Consequences: - Improves user experience while ensuring data access remains scoped via RLS and SECURITY DEFINER RPCs. diff --git a/adr/0006-nav-shell.md b/adr/0006-nav-shell.md index 8648fec..5ad62ca 100644 --- a/adr/0006-nav-shell.md +++ b/adr/0006-nav-shell.md @@ -1,8 +1,9 @@ Title: ADR-0006: Persistent navigation shells Status: Proposed Context: + - Phase 4/6 UX requires persistent bottom navigation shells for clients and workers to improve navigation consistency. -Decision: + Decision: - Implement a persistent Shell layout per role with a top-level nav and a bottom bar that remains visible across routes. -Consequences: + Consequences: - Ensures consistent access to core sections and reduces orphaned routes. Requires careful routing and route guards in the SPA. diff --git a/adr/0007-rpc-pattern.md b/adr/0007-rpc-pattern.md index 2ae9b64..800dac0 100644 --- a/adr/0007-rpc-pattern.md +++ b/adr/0007-rpc-pattern.md @@ -1,8 +1,9 @@ Title: ADR-0007: RPC pattern for SQL-bound actions Status: Proposed Context: + - All mutations rely on SECURITY DEFINER RPCs; we need a robust, auditable pattern for RPC exposure from the app. -Decision: + Decision: - Define naming conventions, input validation with Zod, access guards, and transaction patterns for RPCs such as fund_escrow, submit_milestone, etc. -Consequences: + Consequences: - Aligns development practices and streamlines onboarding for new RPCs while maintaining security guarantees. diff --git a/adr/template.md b/adr/template.md index f36fa4d..8cd69d2 100644 --- a/adr/template.md +++ b/adr/template.md @@ -3,10 +3,13 @@ ADR Template Title: Status: Context: + - Decision: + - Consequences: + - diff --git a/docs/adr/0002-rls-and-security-definer-as-only-escrow-path.md b/docs/adr/0002-rls-and-security-definer-as-only-escrow-path.md index ba3521d..7a1b0ba 100644 --- a/docs/adr/0002-rls-and-security-definer-as-only-escrow-path.md +++ b/docs/adr/0002-rls-and-security-definer-as-only-escrow-path.md @@ -54,7 +54,6 @@ Two designs were considered: - `accept_application(...)` - `cancel_job(...)` - ## Consequences ### Positive @@ -111,4 +110,4 @@ When adding a new RPC under this ADR: - `supabase/migrations/20260514164000_prevent_self_application.sql` — example of business rule in RPC - `supabase/migrations/20260516125000_request_account_deletion_atomic.sql` — atomic idempotency pattern - `supabase/migrations/20260516131000_secure_request_account_deletion_revoke_anon.sql` — defense-in-depth anon revoke pattern -- `supabase/sql/rollbacks/README.md` — rollback file naming and emergency-execution procedure \ No newline at end of file +- `supabase/sql/rollbacks/README.md` — rollback file naming and emergency-execution procedure diff --git a/docs/adr/0003-auth-state-hygiene.md b/docs/adr/0003-auth-state-hygiene.md index adecbf2..8c08223 100644 --- a/docs/adr/0003-auth-state-hygiene.md +++ b/docs/adr/0003-auth-state-hygiene.md @@ -135,4 +135,4 @@ A future Phase 5 task is to encode these as ESLint rules (custom rule set under - `src/lib/supabase/server.ts` — server-side client (separate file, not covered here) - `src/components/account/sign-out-button.tsx` — example call site of the sign-out helper - `src/components/account/delete-account-dialog.tsx` — example call site post-deletion -- ADR 0002 — RLS + SECURITY DEFINER as the only escrow path \ No newline at end of file +- ADR 0002 — RLS + SECURITY DEFINER as the only escrow path diff --git a/docs/adr/0004-migration-discipline.md b/docs/adr/0004-migration-discipline.md index 6a3802a..db21f0e 100644 --- a/docs/adr/0004-migration-discipline.md +++ b/docs/adr/0004-migration-discipline.md @@ -154,4 +154,4 @@ When reviewing a PR that adds a migration, confirm: - Supabase MCP — `apply_migration`, `get_advisors` - ADR 0001 — Escrow state machine in Postgres - ADR 0002 — RLS + SECURITY DEFINER as the only escrow path -- ADR 0003 — Auth state hygiene \ No newline at end of file +- ADR 0003 — Auth state hygiene diff --git a/docs/data-model.md b/docs/data-model.md index 73f0caf..222a3f7 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -1,6 +1,7 @@ Data Model — Pakka Escrow Marketplace Overview + - Pakka is a two-sided marketplace for local trades where clients post jobs and fund milestones. Escrow funds move only via SECURITY DEFINER functions and every mutation is audited through RLS-protected rows with server-side authorization. - The data model emphasizes a strong separation of concerns: profiles define users, wallets track balances, jobs describe work, milestones drive funding, and the ledger records all money movements in an append-only fashion. @@ -19,6 +20,7 @@ Tables - notifications (id uuid PK, recipient_id uuid REFERENCES profiles(id), type text, title text, body text, data jsonb, read_at timestamptz, created_at timestamptz DEFAULT now()) Indexes (suggested) + - CREATE INDEX ON wallets(profile_id); - CREATE INDEX ON jobs(client_id); - CREATE INDEX ON jobs(worker_id); @@ -31,6 +33,7 @@ Indexes (suggested) - CREATE INDEX ON notifications(recipient_id); RLS and Security Intent + - profiles: user reads own row only; cross-user reads go through SECURITY DEFINER RPCs. - wallets: read protected; writes only via SECURITY DEFINER functions; client cannot mutate directly. - escrow_ledger: read-only for participants of the associated job via RPCs; insertions occur via Edge Functions (SECURITY DEFINER). @@ -39,6 +42,7 @@ RLS and Security Intent - All money-related fields and balances computed via server-side RPCs; no client-side balance math. Security Definer Helpers (high level) + - is_admin() returns boolean - fund_escrow(milestone_id) - submit_milestone(milestone_id) @@ -49,6 +53,7 @@ Security Definer Helpers (high level) - auto_release_milestones() Notes + - All money movement must be atomic and audited via escrow_ledger with zero-sum invariants. - Use SELECT FOR UPDATE when mutating wallets in SECURITY DEFINER calls to avoid race conditions. - RLS policies should be as restrictive as possible with admin bypass only where required. diff --git a/docs/state-machine.md b/docs/state-machine.md index 8bd5ddd..6638279 100644 --- a/docs/state-machine.md +++ b/docs/state-machine.md @@ -1,63 +1,77 @@ State Machine — Pakka Milestones Overview + - The escrow flow is driven by two interconnected state machines: - 1) jobs.status: the lifecycle of a job (open, assigned, in_progress, completed, cancelled, disputed). - 2) milestones.status: per-milestone funding and delivery workflow (pending, funded, submitted, approved, disputed, released, refunded). + +1. jobs.status: the lifecycle of a job (open, assigned, in_progress, completed, cancelled, disputed). +2. milestones.status: per-milestone funding and delivery workflow (pending, funded, submitted, approved, disputed, released, refunded). + - All transitions must occur through SECURITY DEFINER Postgres functions. No direct client mutation of statuses. Milestones State Transitions -1) pending -> funded + +1. pending -> funded + - Trigger: fund_escrow(milestone_id) - Effect: milestone.status becomes 'funded'; atomic ledger entry created; client.locked_balance increases by amount; escrow_ledger updated (fund entry). - Caller: client action initiating escrow funding. -2) funded -> submitted +2. funded -> submitted + - Trigger: submit_milestone(milestone_id) when worker submits proof and clicks Submit for Review. - Effect: milestone.status = 'submitted'; submitted_at = now(); auto_release_at = now() + interval '72 hours'. - Caller: worker action. -3) submitted -> approved +3. submitted -> approved + - Trigger: approve_milestone(milestone_id) - Effect: escrow_held decreases by milestone amount; worker.available increases by amount; milestone.status = 'released' or 'approved' depending on design (commonly 'released' in ledger terms). - Caller: client action. - Also: emits realtime notification to both parties. -4) submitted -> disputed +4. submitted -> disputed + - Trigger: dispute_milestone(milestone_id, reason) - Effect: milestone.status = 'disputed'; create disputes row; funds remain locked; notification sent. - Caller: client action. -5) submitted -> auto-release (cron) +5. submitted -> auto-release (cron) + - Trigger: auto_release_milestones() scheduler - Effect: if auto_release_at <= now() and no dispute exists for milestone, milestone.status -> 'released'; worker wallet updated; ledger entry created; realtime notification. - Caller: Edge Function cron. -6) disputed -> resolved_client +6. disputed -> resolved_client + - Trigger: admin_refund or adjudicated refund - Effect: escrow_held -= amount; client.available += amount; milestone.status = 'refunded' or 'released' depending on outcome; create ledger entries and notification. - Caller: admin action. -7) disputed -> resolved_worker +7. disputed -> resolved_worker + - Trigger: admin_force_release - Effect: same as approved, but initiated by admin. - Caller: admin action. -8) disputed -> split +8. disputed -> split + - Trigger: admin splits funds between parties using a dedicated SQL function. - Effect: two ledger entries created; milestone.status = 'released' as per split outcome; wallet balances updated accordingly. - Caller: admin action. Cross-cutting considerations + - All writes go through SECURITY DEFINER functions; client code should never mutate status directly. - RLS should constrain reads to the appropriate scope (owner, job participants, admin). - Realtime subscriptions should be wired to milestone updates so both client and worker UIs reflect changes immediately. - Idempotency: actions accept an Idempotency-Key; duplicate calls should be safely ignored. Key Callers by Transition + - fund_escrow: client action (Phase 3/4 EFT) - submit_milestone: worker action - approve_milestone: client action - dispute_milestone: client action - auto_release_milestones: edge cron -- admin_*: admin role for overrides, refunds, releases, splits +- admin\_\*: admin role for overrides, refunds, releases, splits diff --git a/scripts/seed-demo-users.ts b/scripts/seed-demo-users.ts index b445563..0f96cd9 100644 --- a/scripts/seed-demo-users.ts +++ b/scripts/seed-demo-users.ts @@ -11,7 +11,6 @@ import { createClient } from "@supabase/supabase-js"; - // --------------------------------------------------------------------------- // Env + client // --------------------------------------------------------------------------- @@ -52,15 +51,76 @@ type DemoUser = { const DEMO_USERS: DemoUser[] = [ // Clients - { phone: "+919876500001", name: "Priya Sharma", role: "client", city: "Mumbai", available: 150000, locked: 40000 }, - { phone: "+919876500002", name: "Rohit Mehta", role: "client", city: "Bengaluru", available: 80000, locked: 0 }, - { phone: "+919876500003", name: "Anjali Reddy", role: "client", city: "Hyderabad", available: 250000, locked: 120000 }, + { + phone: "+919876500001", + name: "Priya Sharma", + role: "client", + city: "Mumbai", + available: 150000, + locked: 40000, + }, + { + phone: "+919876500002", + name: "Rohit Mehta", + role: "client", + city: "Bengaluru", + available: 80000, + locked: 0, + }, + { + phone: "+919876500003", + name: "Anjali Reddy", + role: "client", + city: "Hyderabad", + available: 250000, + locked: 120000, + }, // Workers - { phone: "+919876500011", name: "Ravi Kumar", role: "worker", city: "Mumbai", kyc: "verified", trust: "gold", categories: ["masonry", "plumbing"] }, - { phone: "+919876500012", name: "Suresh Patel", role: "worker", city: "Pune", kyc: "verified", trust: "silver", categories: ["electrical"] }, - { phone: "+919876500013", name: "Manoj Yadav", role: "worker", city: "Bengaluru", kyc: "verified", trust: "bronze", categories: ["painting", "carpentry"] }, - { phone: "+919876500014", name: "Deepak Singh", role: "worker", city: "Hyderabad", kyc: "pending", trust: "bronze", categories: ["plumbing"] }, - { phone: "+919876500015", name: "Arjun Nair", role: "worker", city: "Chennai", kyc: "rejected", trust: "bronze", categories: ["electrical"] }, + { + phone: "+919876500011", + name: "Ravi Kumar", + role: "worker", + city: "Mumbai", + kyc: "verified", + trust: "gold", + categories: ["masonry", "plumbing"], + }, + { + phone: "+919876500012", + name: "Suresh Patel", + role: "worker", + city: "Pune", + kyc: "verified", + trust: "silver", + categories: ["electrical"], + }, + { + phone: "+919876500013", + name: "Manoj Yadav", + role: "worker", + city: "Bengaluru", + kyc: "verified", + trust: "bronze", + categories: ["painting", "carpentry"], + }, + { + phone: "+919876500014", + name: "Deepak Singh", + role: "worker", + city: "Hyderabad", + kyc: "pending", + trust: "bronze", + categories: ["plumbing"], + }, + { + phone: "+919876500015", + name: "Arjun Nair", + role: "worker", + city: "Chennai", + kyc: "rejected", + trust: "bronze", + categories: ["electrical"], + }, // Admin { phone: "+919876500099", name: "Demo Admin", role: "admin", city: "Mumbai" }, ]; @@ -196,12 +256,12 @@ async function seed() { console.log(`${icon} ${r.phone} ${r.name.padEnd(16)} ${r.status} (${r.id.slice(0, 8)}…)`); } - const priya = uid["+919876500001"]!; - const rohit = uid["+919876500002"]!; + const priya = uid["+919876500001"]!; + const rohit = uid["+919876500002"]!; const anjali = uid["+919876500003"]!; - const ravi = uid["+919876500011"]!; + const ravi = uid["+919876500011"]!; const suresh = uid["+919876500012"]!; - const manoj = uid["+919876500013"]!; + const manoj = uid["+919876500013"]!; const deepak = uid["+919876500014"]!; // --- 2. Jobs + milestones + ledger + misc -------------------------------- @@ -269,30 +329,75 @@ async function seed() { // ── JOB 1: 2BHK kitchen renovation (Priya → Ravi) ─────────────────────── const job1 = await upsertJob({ - client_id: priya, worker_id: ravi, + client_id: priya, + worker_id: ravi, title: "2BHK kitchen renovation", - description: "Full kitchen renovation including demolition, plumbing, cabinets, countertop, and fixtures.", + description: + "Full kitchen renovation including demolition, plumbing, cabinets, countertop, and fixtures.", category: "masonry", location_text: "Bandra West, Mumbai", total_budget: 180000, status: "assigned", accepted_at: new Date(Date.now() - 10 * 86400_000).toISOString(), }); - const j1m1 = await upsertMilestone(job1, 1, { title: "Demo & rough plumbing", amount: 40000, status: "released", approved_at: new Date(Date.now() - 7 * 86400_000).toISOString() }); - const j1m2 = await upsertMilestone(job1, 2, { title: "Cabinet install", amount: 60000, status: "funded", auto_release_at: new Date(Date.now() + 48 * 3600_000).toISOString() }); - await upsertMilestone(job1, 3, { title: "Countertop & backsplash", amount: 50000, status: "pending" }); - await upsertMilestone(job1, 4, { title: "Final fixtures & cleanup", amount: 30000, status: "pending" }); + const j1m1 = await upsertMilestone(job1, 1, { + title: "Demo & rough plumbing", + amount: 40000, + status: "released", + approved_at: new Date(Date.now() - 7 * 86400_000).toISOString(), + }); + const j1m2 = await upsertMilestone(job1, 2, { + title: "Cabinet install", + amount: 60000, + status: "funded", + auto_release_at: new Date(Date.now() + 48 * 3600_000).toISOString(), + }); + await upsertMilestone(job1, 3, { + title: "Countertop & backsplash", + amount: 50000, + status: "pending", + }); + await upsertMilestone(job1, 4, { + title: "Final fixtures & cleanup", + amount: 30000, + status: "pending", + }); // Ledger for job1: // M1: fund (priya→priya ₹40k) + release (priya→ravi ₹40k) - await upsertLedger({ job_id: job1, milestone_id: j1m1, from_wallet: priya, to_wallet: priya, amount: 40000, type: "fund", reference_id: j1m1 }); - await upsertLedger({ job_id: job1, milestone_id: j1m1, from_wallet: priya, to_wallet: ravi, amount: 40000, type: "release", reference_id: j1m1 }); + await upsertLedger({ + job_id: job1, + milestone_id: j1m1, + from_wallet: priya, + to_wallet: priya, + amount: 40000, + type: "fund", + reference_id: j1m1, + }); + await upsertLedger({ + job_id: job1, + milestone_id: j1m1, + from_wallet: priya, + to_wallet: ravi, + amount: 40000, + type: "release", + reference_id: j1m1, + }); // M2: fund (priya locked ₹60k) - await upsertLedger({ job_id: job1, milestone_id: j1m2, from_wallet: priya, to_wallet: priya, amount: 60000, type: "fund", reference_id: j1m2 }); + await upsertLedger({ + job_id: job1, + milestone_id: j1m2, + from_wallet: priya, + to_wallet: priya, + amount: 60000, + type: "fund", + reference_id: j1m2, + }); // ── JOB 2: Bathroom rewiring (Rohit → Suresh) ──────────────────────────── const job2 = await upsertJob({ - client_id: rohit, worker_id: suresh, + client_id: rohit, + worker_id: suresh, title: "Bathroom rewiring", description: "Complete rewiring of two bathrooms including safety check and fixture install.", category: "electrical", @@ -301,11 +406,25 @@ async function seed() { status: "in_progress", accepted_at: new Date(Date.now() - 5 * 86400_000).toISOString(), }); - const j2m1 = await upsertMilestone(job2, 1, { title: "Wiring + safety check", amount: 20000, status: "submitted", submitted_at: new Date(Date.now() - 1 * 86400_000).toISOString(), auto_release_at: new Date(Date.now() + 71 * 3600_000).toISOString() }); + const j2m1 = await upsertMilestone(job2, 1, { + title: "Wiring + safety check", + amount: 20000, + status: "submitted", + submitted_at: new Date(Date.now() - 1 * 86400_000).toISOString(), + auto_release_at: new Date(Date.now() + 71 * 3600_000).toISOString(), + }); await upsertMilestone(job2, 2, { title: "Fixture install", amount: 15000, status: "pending" }); // Ledger for job2 M1: funded (rohit locked ₹20k) - await upsertLedger({ job_id: job2, milestone_id: j2m1, from_wallet: rohit, to_wallet: rohit, amount: 20000, type: "fund", reference_id: j2m1 }); + await upsertLedger({ + job_id: job2, + milestone_id: j2m1, + from_wallet: rohit, + to_wallet: rohit, + amount: 20000, + type: "fund", + reference_id: j2m1, + }); // Proof for M1 const { data: existingProof } = await supabase @@ -326,7 +445,8 @@ async function seed() { // ── JOB 3: Office repaint (Anjali → Manoj, disputed) ───────────────────── const job3 = await upsertJob({ - client_id: anjali, worker_id: manoj, + client_id: anjali, + worker_id: manoj, title: "Office repaint 1500 sqft", description: "Full office repaint — walls and ceiling. Premium washable paint specified.", category: "painting", @@ -335,13 +455,50 @@ async function seed() { status: "disputed", accepted_at: new Date(Date.now() - 15 * 86400_000).toISOString(), }); - const j3m1 = await upsertMilestone(job3, 1, { title: "Surface prep & primer", amount: 20000, status: "released", approved_at: new Date(Date.now() - 10 * 86400_000).toISOString() }); - const j3m2 = await upsertMilestone(job3, 2, { title: "First coat", amount: 30000, status: "disputed" }); - await upsertMilestone(job3, 3, { title: "Second coat & finishing", amount: 15000, status: "pending" }); + const j3m1 = await upsertMilestone(job3, 1, { + title: "Surface prep & primer", + amount: 20000, + status: "released", + approved_at: new Date(Date.now() - 10 * 86400_000).toISOString(), + }); + const j3m2 = await upsertMilestone(job3, 2, { + title: "First coat", + amount: 30000, + status: "disputed", + }); + await upsertMilestone(job3, 3, { + title: "Second coat & finishing", + amount: 15000, + status: "pending", + }); - await upsertLedger({ job_id: job3, milestone_id: j3m1, from_wallet: anjali, to_wallet: anjali, amount: 20000, type: "fund", reference_id: j3m1 }); - await upsertLedger({ job_id: job3, milestone_id: j3m1, from_wallet: anjali, to_wallet: manoj, amount: 20000, type: "release", reference_id: j3m1 }); - await upsertLedger({ job_id: job3, milestone_id: j3m2, from_wallet: anjali, to_wallet: anjali, amount: 30000, type: "fund", reference_id: j3m2 }); + await upsertLedger({ + job_id: job3, + milestone_id: j3m1, + from_wallet: anjali, + to_wallet: anjali, + amount: 20000, + type: "fund", + reference_id: j3m1, + }); + await upsertLedger({ + job_id: job3, + milestone_id: j3m1, + from_wallet: anjali, + to_wallet: manoj, + amount: 20000, + type: "release", + reference_id: j3m1, + }); + await upsertLedger({ + job_id: job3, + milestone_id: j3m2, + from_wallet: anjali, + to_wallet: anjali, + amount: 30000, + type: "fund", + reference_id: j3m2, + }); // Dispute for M2 const { data: existingDispute } = await supabase @@ -372,7 +529,7 @@ async function seed() { status: "open", }); await upsertMilestone(job4, 1, { title: "Frame & assembly", amount: 15000, status: "pending" }); - await upsertMilestone(job4, 2, { title: "Finishing & polish", amount: 7000, status: "pending" }); + await upsertMilestone(job4, 2, { title: "Finishing & polish", amount: 7000, status: "pending" }); // ── JOB 5: Drainage repair (Priya, open, 2 applications) ───────────────── const job5 = await upsertJob({ @@ -384,12 +541,28 @@ async function seed() { total_budget: 15000, status: "open", }); - await upsertMilestone(job5, 1, { title: "Diagnose & repair drainage", amount: 15000, status: "pending" }); + await upsertMilestone(job5, 1, { + title: "Diagnose & repair drainage", + amount: 15000, + status: "pending", + }); // Applications for job5 for (const app of [ - { worker_id: ravi, bid_amount: 14000, eta_days: 3, message: "Can start immediately. Gold tier plumber.", status: "pending" }, - { worker_id: deepak, bid_amount: 12000, eta_days: 5, message: "KYC pending but available.", status: "rejected" }, + { + worker_id: ravi, + bid_amount: 14000, + eta_days: 3, + message: "Can start immediately. Gold tier plumber.", + status: "pending", + }, + { + worker_id: deepak, + bid_amount: 12000, + eta_days: 5, + message: "KYC pending but available.", + status: "rejected", + }, ]) { const { data: existingApp } = await supabase .from("job_applications") @@ -398,14 +571,17 @@ async function seed() { .eq("worker_id", app.worker_id) .maybeSingle(); if (!existingApp) { - const { error: appErr } = await supabase.from("job_applications").insert({ job_id: job5, ...app }); + const { error: appErr } = await supabase + .from("job_applications") + .insert({ job_id: job5, ...app }); if (appErr) bail(`job_applications insert job5 worker=${app.worker_id}`, appErr); } } // ── JOB 6: Full bathroom remodel (Rohit → Ravi, completed) ─────────────── const job6 = await upsertJob({ - client_id: rohit, worker_id: ravi, + client_id: rohit, + worker_id: ravi, title: "Full bathroom remodel", description: "Complete gut-and-remodel of master bathroom. Tiling, plumbing, fixtures.", category: "plumbing", @@ -414,13 +590,48 @@ async function seed() { status: "completed", accepted_at: new Date(Date.now() - 45 * 86400_000).toISOString(), }); - const j6m1 = await upsertMilestone(job6, 1, { title: "Demo & waterproofing", amount: 30000, status: "released", approved_at: new Date(Date.now() - 35 * 86400_000).toISOString() }); - const j6m2 = await upsertMilestone(job6, 2, { title: "Tiling & plumbing rough-in", amount: 45000, status: "released", approved_at: new Date(Date.now() - 20 * 86400_000).toISOString() }); - const j6m3 = await upsertMilestone(job6, 3, { title: "Fixtures & final cleanup", amount: 20000, status: "released", approved_at: new Date(Date.now() - 10 * 86400_000).toISOString() }); + const j6m1 = await upsertMilestone(job6, 1, { + title: "Demo & waterproofing", + amount: 30000, + status: "released", + approved_at: new Date(Date.now() - 35 * 86400_000).toISOString(), + }); + const j6m2 = await upsertMilestone(job6, 2, { + title: "Tiling & plumbing rough-in", + amount: 45000, + status: "released", + approved_at: new Date(Date.now() - 20 * 86400_000).toISOString(), + }); + const j6m3 = await upsertMilestone(job6, 3, { + title: "Fixtures & final cleanup", + amount: 20000, + status: "released", + approved_at: new Date(Date.now() - 10 * 86400_000).toISOString(), + }); - for (const [mid, amt] of [[j6m1, 30000], [j6m2, 45000], [j6m3, 20000]] as [string, number][]) { - await upsertLedger({ job_id: job6, milestone_id: mid, from_wallet: rohit, to_wallet: rohit, amount: amt, type: "fund", reference_id: mid }); - await upsertLedger({ job_id: job6, milestone_id: mid, from_wallet: rohit, to_wallet: ravi, amount: amt, type: "release", reference_id: mid }); + for (const [mid, amt] of [ + [j6m1, 30000], + [j6m2, 45000], + [j6m3, 20000], + ] as [string, number][]) { + await upsertLedger({ + job_id: job6, + milestone_id: mid, + from_wallet: rohit, + to_wallet: rohit, + amount: amt, + type: "fund", + reference_id: mid, + }); + await upsertLedger({ + job_id: job6, + milestone_id: mid, + from_wallet: rohit, + to_wallet: ravi, + amount: amt, + type: "release", + reference_id: mid, + }); } // Also top-up Ravi's wallet for the released funds (job1 M1 + job6 all = 40k + 95k = 135k) @@ -453,17 +664,19 @@ async function seed() { // For release entries: money moves out of locked (from_wallet), into available (to_wallet) // Net system balance = sum(release) - sum(fund) per wallet should be 0 in a closed system // Simplified check: total fund amounts == total release amounts (no refunds in seed) - let totalFund = 0, totalRelease = 0, totalRefund = 0; + let totalFund = 0, + totalRelease = 0, + totalRefund = 0; for (const row of ledgerRows ?? []) { - if (row.type === "fund") totalFund += Number(row.amount); + if (row.type === "fund") totalFund += Number(row.amount); if (row.type === "release") totalRelease += Number(row.amount); - if (row.type === "refund") totalRefund += Number(row.amount); + if (row.type === "refund") totalRefund += Number(row.amount); } console.log(` Total funded : ${fmt(totalFund)}`); console.log(` Total released: ${fmt(totalRelease)}`); console.log(` Total refunded: ${fmt(totalRefund)}`); - const locked = totalFund - totalRelease - totalRefund; + const locked = totalFund - totalRelease - totalRefund; const ledgerOk = locked >= 0; console.log(` In-escrow lock: ${fmt(locked)} ${ledgerOk ? "✅ balanced" : "❌ MISMATCH"}`); if (!ledgerOk) { diff --git a/src/app/_actions/jobs.ts b/src/app/_actions/jobs.ts index e596d99..ed4f33e 100644 --- a/src/app/_actions/jobs.ts +++ b/src/app/_actions/jobs.ts @@ -27,9 +27,7 @@ async function getAuthUserId() { } // ── Post a job ──────────────────────────────────────────────────────────────── -export async function postJobAction( - raw: PostJobInput, -): Promise> { +export async function postJobAction(raw: PostJobInput): Promise> { try { const parsed = postJobSchema.safeParse(raw); if (!parsed.success) { @@ -40,8 +38,17 @@ export async function postJobAction( } const { supabase, userId } = await getAuthUserId(); - const { title, category, description, location_text, lat, lng, total_budget, milestones, materials } = - parsed.data; + const { + title, + category, + description, + location_text, + lat, + lng, + total_budget, + milestones, + materials, + } = parsed.data; // Insert job const { data: job, error: jobError } = await supabase @@ -107,9 +114,7 @@ export async function postJobAction( } // ── Apply to job ────────────────────────────────────────────────────────────── -export async function applyToJobAction( - raw: ApplyJobInput, -): Promise { +export async function applyToJobAction(raw: ApplyJobInput): Promise { try { const parsed = applyJobSchema.safeParse(raw); if (!parsed.success) { @@ -163,9 +168,7 @@ export async function applyToJobAction( } // ── Accept worker ───────────────────────────────────────────────────────────── -export async function acceptWorkerAction( - raw: AcceptWorkerInput, -): Promise { +export async function acceptWorkerAction(raw: AcceptWorkerInput): Promise { try { const parsed = acceptWorkerSchema.safeParse(raw); if (!parsed.success) { @@ -216,10 +219,7 @@ export async function acceptWorkerAction( .eq("id", job_id) .eq("client_id", userId), - supabase - .from("job_applications") - .update({ status: "accepted" }) - .eq("id", application_id), + supabase.from("job_applications").update({ status: "accepted" }).eq("id", application_id), supabase .from("job_applications") diff --git a/src/app/_actions/wallet.ts b/src/app/_actions/wallet.ts index 2f13db0..eb53654 100644 --- a/src/app/_actions/wallet.ts +++ b/src/app/_actions/wallet.ts @@ -10,7 +10,8 @@ import { } from "@/lib/schemas/wallet"; import type { ActionResult } from "./escrow"; -// ── Helper ──────────────────────────────────────────────────────────────────── +// ── Helpers ─────────────────────────────────────────────────────────────────── + async function getAuthUserId() { const supabase = await createClient(); const { @@ -21,8 +22,51 @@ async function getAuthUserId() { return { supabase, userId: user.id }; } +/** + * Map a Postgres RPC error from topup_wallet / withdraw_wallet into a + * user-friendly string. Routes by SQLSTATE first (error.code), then by the + * exact exception token from RAISE EXCEPTION (error.message). + * + * Returns null when the error is unrecognised; callers should log raw + + * fall back to a generic message. + * + * Replaces the previous error.message substring matching (PR #19 review, + * CodeRabbit #1) with a deterministic SQLSTATE + exact-token dispatch. + */ +function mapWalletRpcError( + action: "topup" | "withdraw", + error: { code: string; message: string }, +): string | null { + // 42501 — insufficient privilege / auth-related + if (error.code === "42501") { + if (error.message === "not_authenticated") return "You need to be signed in."; + if (error.message === "forbidden_role") { + return action === "withdraw" + ? "Withdrawals are restricted to worker accounts." + : "You don't have permission for this operation."; + } + return "You don't have permission for this operation."; + } + // 22023 — invalid parameter value + if (error.code === "22023") { + if (error.message === "invalid_amount") { + return action === "topup" + ? "Amount must be between ₹100 and ₹1,00,000." + : "Amount must be at least ₹100."; + } + if (error.message === "invalid_idempotency_key") { + return "Request signature missing. Please refresh and try again."; + } + if (error.message === "insufficient_balance") return "Not enough balance in your wallet."; + if (error.message === "wallet_not_found") return "Wallet not found. Please contact support."; + return "Invalid input. Please check the amount and try again."; + } + return null; +} + // ── Top up Wallet ───────────────────────────────────────────────────────────── // external → wallet: calls topup_wallet() SECURITY DEFINER function (test mode) + export async function topupWalletAction( raw: TopupWalletInput, ): Promise> { @@ -41,13 +85,14 @@ export async function topupWalletAction( }); if (error) { - const msg = error.message.toLowerCase(); - if (msg.includes("invalid_amount")) { - return { success: false, error: "Amount must be between ₹100 and ₹1,00,000." }; - } - if (msg.includes("not_authenticated")) { - return { success: false, error: "You need to be signed in." }; - } + const friendly = mapWalletRpcError("topup", error); + if (friendly) return { success: false, error: friendly }; + console.error("[wallet.topupWalletAction] Unmapped RPC error", { + code: error.code, + message: error.message, + details: error.details, + }); + // TODO: Sentry.captureException(error); return { success: false, error: "Could not top up wallet. Please try again." }; } @@ -67,6 +112,7 @@ export async function topupWalletAction( }, }; } catch (err) { + console.error("[wallet.topupWalletAction] Unexpected error", err); const msg = err instanceof Error ? err.message : "Unknown error"; // TODO: Sentry.captureException(err); return { success: false, error: msg }; @@ -75,6 +121,7 @@ export async function topupWalletAction( // ── Withdraw Wallet ─────────────────────────────────────────────────────────── // wallet → external: calls withdraw_wallet() SECURITY DEFINER function (test mode) + export async function withdrawWalletAction( raw: WithdrawWalletInput, ): Promise> { @@ -85,27 +132,39 @@ export async function withdrawWalletAction( } const { supabase } = await getAuthUserId(); - const { amount, idempotency_key } = parsed.data; + // Defense-in-depth: explicit worker-role pre-check. The DB layer also + // enforces this inside withdraw_wallet via + // `if not public.is_worker() then raise exception 'forbidden_role' ...` + // (PR #19 review, CodeAnt #1: addresses missing role check at action layer.) + const { data: isWorker, error: roleErr } = await supabase.rpc("is_worker"); + if (roleErr) { + console.error("[wallet.withdrawWalletAction] is_worker check failed", { + code: roleErr.code, + message: roleErr.message, + details: roleErr.details, + }); + return { success: false, error: "Could not verify account role. Please try again." }; + } + if (!isWorker) { + return { success: false, error: "Withdrawals are restricted to worker accounts." }; + } + + const { amount, idempotency_key } = parsed.data; const { data, error } = await supabase.rpc("withdraw_wallet", { p_amount: amount, p_idempotency_key: idempotency_key, }); if (error) { - const msg = error.message.toLowerCase(); - if (msg.includes("invalid_amount")) { - return { success: false, error: "Amount must be at least ₹100." }; - } - if (msg.includes("insufficient_balance")) { - return { success: false, error: "Not enough balance in your wallet." }; - } - if (msg.includes("not_authenticated")) { - return { success: false, error: "You need to be signed in." }; - } - if (msg.includes("wallet_not_found")) { - return { success: false, error: "Wallet not found. Please contact support." }; - } + const friendly = mapWalletRpcError("withdraw", error); + if (friendly) return { success: false, error: friendly }; + console.error("[wallet.withdrawWalletAction] Unmapped RPC error", { + code: error.code, + message: error.message, + details: error.details, + }); + // TODO: Sentry.captureException(error); return { success: false, error: "Could not withdraw from wallet. Please try again." }; } @@ -125,8 +184,9 @@ export async function withdrawWalletAction( }, }; } catch (err) { + console.error("[wallet.withdrawWalletAction] Unexpected error", err); const msg = err instanceof Error ? err.message : "Unknown error"; // TODO: Sentry.captureException(err); return { success: false, error: msg }; } -} \ No newline at end of file +} diff --git a/src/app/client/account/page.tsx b/src/app/client/account/page.tsx index bbda77c..b780d83 100644 --- a/src/app/client/account/page.tsx +++ b/src/app/client/account/page.tsx @@ -11,130 +11,130 @@ import { SignOutButton } from "@/components/account/sign-out-button"; import { DeleteAccountDialog } from "@/components/account/delete-account-dialog"; type Profile = { - id: string; - full_name: string | null; - phone: string | null; - city: string | null; - role: string; - created_at: string; + id: string; + full_name: string | null; + phone: string | null; + city: string | null; + role: string; + created_at: string; }; export default function ClientAccountPage() { - const router = useRouter(); - const [profile, setProfile] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const router = useRouter(); + const [profile, setProfile] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - async function load() { - const supabase = createClient(); - setLoading(true); - setError(null); - const { - data: { user }, - } = await supabase.auth.getUser(); - if (!user) { - setLoading(false); - router.replace("/login"); - return; - } - const { data, error: fetchError } = await supabase - .from("profiles") - .select("id, full_name, phone, city, role, created_at") - .eq("id", user.id) - .single(); - if (fetchError) { - setError(fetchError.message); - setLoading(false); - return; - } - setProfile(data as Profile); - setLoading(false); + async function load() { + const supabase = createClient(); + setLoading(true); + setError(null); + const { + data: { user }, + } = await supabase.auth.getUser(); + if (!user) { + setLoading(false); + router.replace("/login"); + return; } + const { data, error: fetchError } = await supabase + .from("profiles") + .select("id, full_name, phone, city, role, created_at") + .eq("id", user.id) + .single(); + if (fetchError) { + setError(fetchError.message); + setLoading(false); + return; + } + setProfile(data as Profile); + setLoading(false); + } - useEffect(() => { - void load(); - }, []); + useEffect(() => { + void load(); + }, []); - if (loading) { - return ( -
-

Account

- - - -
- ); - } + if (loading) { + return ( +
+

Account

+ + + +
+ ); + } - if (error) { - return ( -
-

Account

- - -

Could not load your account

-

{error}

- -
-
-
- ); - } + if (error) { + return ( +
+

Account

+ + +

Could not load your account

+

{error}

+ +
+
+
+ ); + } - if (!profile) return null; + if (!profile) return null; - const profileDefaults = { - full_name: profile.full_name ?? "", - city: profile.city ?? "", - }; + const profileDefaults = { + full_name: profile.full_name ?? "", + city: profile.city ?? "", + }; - return ( -
-

Account

+ return ( +
+

Account

- - - Profile - - - - - - - - - + + + Profile + + + + + + + + + - - - Manage - - - - - - - + + + Manage + + + + + + + - - - Danger zone - - - - - -
- ); + + + Danger zone + + + + + +
+ ); } function Row({ label, value }: { label: string; value: string }) { - return ( -
- {label} - {value} -
- ); -} \ No newline at end of file + return ( +
+ {label} + {value} +
+ ); +} diff --git a/src/app/client/jobs/[id]/client-job-detail.tsx b/src/app/client/jobs/[id]/client-job-detail.tsx index d7daf1a..9475212 100644 --- a/src/app/client/jobs/[id]/client-job-detail.tsx +++ b/src/app/client/jobs/[id]/client-job-detail.tsx @@ -66,7 +66,9 @@ async function fetchJobDetail(jobId: string) { const [jobRes, msRes, matRes, appRes] = await Promise.all([ supabase .from("jobs") - .select("id,title,category,description,location_text,total_budget,status,created_at,worker_id") + .select( + "id,title,category,description,location_text,total_budget,status,created_at,worker_id", + ) .eq("id", jobId) .single(), supabase @@ -101,16 +103,14 @@ async function fetchJobDetail(jobId: string) { if (workerIds.length > 0) { const { data: workerSummaries, error: wsErr } = await supabase.rpc( - 'get_application_worker_summary', + "get_application_worker_summary", { worker_ids: workerIds }, ); if (wsErr) { - console.error('[client-job-detail] worker-summary RPC error:', wsErr); + console.error("[client-job-detail] worker-summary RPC error:", wsErr); // Degrade gracefully: leave workerSummaryMap empty, cards render fallback names/trust tiers. } else { - workerSummaryMap = new Map( - (workerSummaries ?? []).map((w: WorkerSummary) => [w.id, w]), - ); + workerSummaryMap = new Map((workerSummaries ?? []).map((w: WorkerSummary) => [w.id, w])); } } @@ -169,58 +169,75 @@ export function ClientJobDetail() { const supabase = createClient(); const channel = supabase .channel(`client-job-detail-${jobId}`) - .on('postgres_changes', { - event: 'UPDATE', schema: 'public', table: 'jobs', - filter: `id=eq.${jobId}`, - }, (payload) => { - console.log('[client-job-detail-realtime] event:', - payload.table, payload.eventType); - queryClient.invalidateQueries({ queryKey: ['client-job', jobId] }); - }) - .on('postgres_changes', { - event: 'INSERT', schema: 'public', table: 'job_applications', - filter: `job_id=eq.${jobId}`, - }, (payload) => { - console.log('[client-job-detail-realtime] event:', - payload.table, payload.eventType); - // Reuse the SECURITY DEFINER RPC — direct .from("profiles").eq() is - // blocked by RLS for any non-self row, always returning null and - // falling back to the literal "a worker" in the toast. ADR-0034. - const workerId = (payload.new as { worker_id: string }).worker_id; - - // Trigger refresh immediately — DO NOT gate on RPC. - queryClient.invalidateQueries({ queryKey: ['client-job', jobId] }); - - // Enrich toast with worker name; failure is non-fatal. - Promise.resolve( - supabase.rpc('get_application_worker_summary', { worker_ids: [workerId] }), - ) - .then(({ data, error }) => { - if (error) { - console.error('[client-job-detail-realtime] RPC error:', error); - toast.info('Someone just applied!'); - return; - } - const name = data?.[0]?.full_name ?? 'Someone'; - toast.info(`${name} just applied!`); - }) - .catch((err: unknown) => { - console.error('[client-job-detail-realtime] RPC rejected:', err); - toast.info('Someone just applied!'); - }); - }) - .on('postgres_changes', { - event: 'UPDATE', schema: 'public', table: 'job_applications', - filter: `job_id=eq.${jobId}`, - }, (payload) => { - console.log('[client-job-detail-realtime] event:', - payload.table, payload.eventType); - queryClient.invalidateQueries({ queryKey: ['client-job', jobId] }); - }) + .on( + "postgres_changes", + { + event: "UPDATE", + schema: "public", + table: "jobs", + filter: `id=eq.${jobId}`, + }, + (payload) => { + console.log("[client-job-detail-realtime] event:", payload.table, payload.eventType); + queryClient.invalidateQueries({ queryKey: ["client-job", jobId] }); + }, + ) + .on( + "postgres_changes", + { + event: "INSERT", + schema: "public", + table: "job_applications", + filter: `job_id=eq.${jobId}`, + }, + (payload) => { + console.log("[client-job-detail-realtime] event:", payload.table, payload.eventType); + // Reuse the SECURITY DEFINER RPC — direct .from("profiles").eq() is + // blocked by RLS for any non-self row, always returning null and + // falling back to the literal "a worker" in the toast. ADR-0034. + const workerId = (payload.new as { worker_id: string }).worker_id; + + // Trigger refresh immediately — DO NOT gate on RPC. + queryClient.invalidateQueries({ queryKey: ["client-job", jobId] }); + + // Enrich toast with worker name; failure is non-fatal. + Promise.resolve( + supabase.rpc("get_application_worker_summary", { worker_ids: [workerId] }), + ) + .then(({ data, error }) => { + if (error) { + console.error("[client-job-detail-realtime] RPC error:", error); + toast.info("Someone just applied!"); + return; + } + const name = data?.[0]?.full_name ?? "Someone"; + toast.info(`${name} just applied!`); + }) + .catch((err: unknown) => { + console.error("[client-job-detail-realtime] RPC rejected:", err); + toast.info("Someone just applied!"); + }); + }, + ) + .on( + "postgres_changes", + { + event: "UPDATE", + schema: "public", + table: "job_applications", + filter: `job_id=eq.${jobId}`, + }, + (payload) => { + console.log("[client-job-detail-realtime] event:", payload.table, payload.eventType); + queryClient.invalidateQueries({ queryKey: ["client-job", jobId] }); + }, + ) .subscribe((status, err) => { - console.log('[client-job-detail-realtime]', status, err ?? ''); + console.log("[client-job-detail-realtime]", status, err ?? ""); }); - return () => { supabase.removeChannel(channel); }; + return () => { + supabase.removeChannel(channel); + }; }, [user?.id, jobId, queryClient]); // ── Accept handler ──────────────────────────────────────────────────────── @@ -259,7 +276,9 @@ export function ClientJobDetail() {
- {CATEGORY_LABELS[job.category] ?? job.category} + + {CATEGORY_LABELS[job.category] ?? job.category} + · {relativeTime(job.created_at)}
@@ -288,10 +307,10 @@ export function ClientJobDetail() { className="flex items-center justify-between rounded-lg border bg-card px-4 py-3" >
-

{m.sequence}. {m.title}

- {m.description && ( -

{m.description}

- )} +

+ {m.sequence}. {m.title} +

+ {m.description &&

{m.description}

}
{formatInr(m.amount)} diff --git a/src/app/client/jobs/[id]/fund/page.tsx b/src/app/client/jobs/[id]/fund/page.tsx index b591696..41a72e9 100644 --- a/src/app/client/jobs/[id]/fund/page.tsx +++ b/src/app/client/jobs/[id]/fund/page.tsx @@ -2,11 +2,7 @@ import { redirect } from "next/navigation"; // Phase 4: Fund page is now the milestones page. // Redirect any old links pointing here. -export default async function FundEscrowRedirect({ - params, -}: { - params: Promise<{ id: string }>; -}) { +export default async function FundEscrowRedirect({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; redirect(`/client/jobs/${id}/milestones`); } diff --git a/src/app/client/jobs/[id]/milestones/client-milestones.tsx b/src/app/client/jobs/[id]/milestones/client-milestones.tsx index 9bedd82..950693e 100644 --- a/src/app/client/jobs/[id]/milestones/client-milestones.tsx +++ b/src/app/client/jobs/[id]/milestones/client-milestones.tsx @@ -4,14 +4,7 @@ import { useEffect, useRef, useState, useTransition } from "react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useParams } from "next/navigation"; import { toast } from "sonner"; -import { - Lock, - CheckCircle2, - AlertTriangle, - Loader2, - Clock, - Shield, -} from "lucide-react"; +import { Lock, CheckCircle2, AlertTriangle, Loader2, Clock, Shield } from "lucide-react"; import { createClient } from "@/lib/supabase/client"; import { useUser } from "@/hooks/use-user"; import { @@ -70,21 +63,15 @@ async function fetchMilestonesData(jobId: string) { const supabase = createClient(); const [jobRes, msRes, walletRes, workerRes] = await Promise.all([ - supabase - .from("jobs") - .select("id,title,total_budget,status,worker_id") - .eq("id", jobId) - .single(), + supabase.from("jobs").select("id,title,total_budget,status,worker_id").eq("id", jobId).single(), supabase .from("milestones") - .select("id,sequence,title,description,amount,status,auto_release_at,submitted_at,approved_at") + .select( + "id,sequence,title,description,amount,status,auto_release_at,submitted_at,approved_at", + ) .eq("job_id", jobId) .order("sequence"), - supabase - .from("wallets") - .select("available_balance,locked_balance") - .limit(1) - .single(), + supabase.from("wallets").select("available_balance,locked_balance").limit(1).single(), // Get the worker name if assigned supabase .from("jobs") @@ -109,9 +96,9 @@ async function fetchMilestonesData(jobId: string) { })) as Milestone[], wallet: walletRes.data ? { - available_balance: Number(walletRes.data.available_balance), - locked_balance: Number(walletRes.data.locked_balance), - } + available_balance: Number(walletRes.data.available_balance), + locked_balance: Number(walletRes.data.locked_balance), + } : { available_balance: 0, locked_balance: 0 }, workerName, }; @@ -154,35 +141,52 @@ export function ClientMilestones() { const supabase = createClient(); const channel = supabase .channel(`client-milestones-${jobId}`) - .on('postgres_changes', { - event: 'UPDATE', schema: 'public', table: 'milestones', - filter: `job_id=eq.${jobId}`, - }, (payload) => { - console.log('[client-milestones-realtime] event:', - payload.table, payload.eventType); - queryClient.invalidateQueries({ queryKey: ['client-milestones', jobId] }); - queryClient.invalidateQueries({ queryKey: ['client-job', jobId] }); - }) - .on('postgres_changes', { - event: '*', schema: 'public', table: 'escrow_ledger', - filter: `job_id=eq.${jobId}`, - }, (payload) => { - console.log('[client-milestones-realtime] event:', - payload.table, payload.eventType); - queryClient.invalidateQueries({ queryKey: ['client-milestones', jobId] }); - }) - .on('postgres_changes', { - event: 'UPDATE', schema: 'public', table: 'wallets', - filter: `profile_id=eq.${userId}`, - }, (payload) => { - console.log('[client-milestones-realtime] event:', - payload.table, payload.eventType); - queryClient.invalidateQueries({ queryKey: ['client-milestones', jobId] }); - }) + .on( + "postgres_changes", + { + event: "UPDATE", + schema: "public", + table: "milestones", + filter: `job_id=eq.${jobId}`, + }, + (payload) => { + console.log("[client-milestones-realtime] event:", payload.table, payload.eventType); + queryClient.invalidateQueries({ queryKey: ["client-milestones", jobId] }); + queryClient.invalidateQueries({ queryKey: ["client-job", jobId] }); + }, + ) + .on( + "postgres_changes", + { + event: "*", + schema: "public", + table: "escrow_ledger", + filter: `job_id=eq.${jobId}`, + }, + (payload) => { + console.log("[client-milestones-realtime] event:", payload.table, payload.eventType); + queryClient.invalidateQueries({ queryKey: ["client-milestones", jobId] }); + }, + ) + .on( + "postgres_changes", + { + event: "UPDATE", + schema: "public", + table: "wallets", + filter: `profile_id=eq.${userId}`, + }, + (payload) => { + console.log("[client-milestones-realtime] event:", payload.table, payload.eventType); + queryClient.invalidateQueries({ queryKey: ["client-milestones", jobId] }); + }, + ) .subscribe((status, err) => { - console.log('[client-milestones-realtime]', status, err ?? ''); + console.log("[client-milestones-realtime]", status, err ?? ""); }); - return () => { supabase.removeChannel(channel); }; + return () => { + supabase.removeChannel(channel); + }; }, [user?.id, jobId, queryClient]); // ── Action handlers ───────────────────────────────────────────────────── @@ -296,9 +300,7 @@ export function ClientMilestones() {
{/* ── Job header ── */}
-

- {job.title} -

+

{job.title}

Escrow milestones · {milestones.length} total

@@ -310,9 +312,7 @@ export function ClientMilestones() {

Available

-

- {formatInr(wallet.available_balance)} -

+

{formatInr(wallet.available_balance)}

@@ -321,9 +321,7 @@ export function ClientMilestones() { Locked

-

- {formatInr(wallet.locked_balance)} -

+

{formatInr(wallet.locked_balance)}

@@ -406,19 +404,13 @@ export function ClientMilestones() { -