From 80338766b27c02d04d841ccd8c52dda965e63e67 Mon Sep 17 00:00:00 2001 From: Mercy Duru Date: Thu, 25 Jun 2026 20:25:55 +0100 Subject: [PATCH 1/3] Global Error Boundary, 404 Page, Loading Skeletons & Toast System Standardization --- frontend/app/(dashboard)/bids/page.tsx | 143 ++++--- frontend/app/(dashboard)/error.tsx | 73 ++++ frontend/app/(dashboard)/marketplace/page.tsx | 161 ++++--- .../(dashboard)/settings/security/page.tsx | 403 ++++++++++++------ frontend/app/(dashboard)/shipments/page.tsx | 107 +++-- frontend/app/global-error.tsx | 65 +++ frontend/app/layout.tsx | 4 +- frontend/app/not-found.tsx | 111 ++++- .../components/skeletons/KpiCardSkeleton.tsx | 12 + .../skeletons/NotificationItemSkeleton.tsx | 15 + .../components/skeletons/ProfileSkeleton.tsx | 14 + .../components/skeletons/TableRowSkeleton.tsx | 19 + frontend/components/skeletons/index.ts | 12 + frontend/components/ui/empty-state.tsx | 228 ++++++++-- frontend/package-lock.json | 11 - 15 files changed, 1034 insertions(+), 344 deletions(-) create mode 100644 frontend/app/(dashboard)/error.tsx create mode 100644 frontend/app/global-error.tsx create mode 100644 frontend/components/skeletons/KpiCardSkeleton.tsx create mode 100644 frontend/components/skeletons/NotificationItemSkeleton.tsx create mode 100644 frontend/components/skeletons/ProfileSkeleton.tsx create mode 100644 frontend/components/skeletons/TableRowSkeleton.tsx create mode 100644 frontend/components/skeletons/index.ts diff --git a/frontend/app/(dashboard)/bids/page.tsx b/frontend/app/(dashboard)/bids/page.tsx index 9d9c0068..c6db6757 100644 --- a/frontend/app/(dashboard)/bids/page.tsx +++ b/frontend/app/(dashboard)/bids/page.tsx @@ -1,27 +1,40 @@ -'use client'; +"use client"; -import { useCallback, useEffect, useRef, useState } from 'react'; -import Link from 'next/link'; -import { toast } from 'sonner'; -import { bidApi, Bid, BidStatus } from '../../../lib/api/bid.api'; -import { Card, CardContent, CardHeader, CardTitle } from '../../../components/ui/card'; -import { Button } from '../../../components/ui/button'; +import { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { toast } from "sonner"; +import { bidApi, Bid, BidStatus } from "../../../lib/api/bid.api"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { Button } from "../../../components/ui/button"; +import { TableRowSkeleton } from "../../../components/skeletons"; +import { EmptyBids } from "../../../components/ui/empty-state"; function getBidStatusClass(status: BidStatus): string { switch (status) { - case 'PENDING': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'; - case 'ACCEPTED': - case 'COUNTER_ACCEPTED': return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'; - case 'REJECTED': - case 'COUNTER_REJECTED': return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'; - case 'COUNTER_OFFERED': return 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300'; - case 'EXPIRED': return 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'; - default: return 'bg-muted text-muted-foreground'; + case "PENDING": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300"; + case "ACCEPTED": + case "COUNTER_ACCEPTED": + return "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300"; + case "REJECTED": + case "COUNTER_REJECTED": + return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"; + case "COUNTER_OFFERED": + return "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"; + case "EXPIRED": + return "bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400"; + default: + return "bg-muted text-muted-foreground"; } } function ExpiryCountdown({ expiresAt }: { expiresAt?: string }) { - const [label, setLabel] = useState(''); + const [label, setLabel] = useState(""); useEffect(() => { if (!expiresAt) return; @@ -29,7 +42,7 @@ function ExpiryCountdown({ expiresAt }: { expiresAt?: string }) { const update = () => { const diff = new Date(expiresAt).getTime() - Date.now(); if (diff <= 0) { - setLabel('Expired'); + setLabel("Expired"); return; } const hours = Math.floor(diff / 3_600_000); @@ -43,9 +56,11 @@ function ExpiryCountdown({ expiresAt }: { expiresAt?: string }) { }, [expiresAt]); if (!expiresAt) return null; - const expired = label === 'Expired'; + const expired = label === "Expired"; return ( - + {label} ); @@ -55,7 +70,10 @@ export default function BidsDashboardPage() { const [bids, setBids] = useState([]); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(null); - const confirmRef = useRef<{ bidId: string; action: 'accept' | 'decline' } | null>(null); + const confirmRef = useRef<{ + bidId: string; + action: "accept" | "decline"; + } | null>(null); const [showConfirm, setShowConfirm] = useState(false); const load = useCallback(async () => { @@ -63,15 +81,17 @@ export default function BidsDashboardPage() { const data = await bidApi.listMyBids(); setBids(data); } catch { - toast.error('Failed to load bids'); + toast.error("Failed to load bids"); } finally { setLoading(false); } }, []); - useEffect(() => { load(); }, [load]); + useEffect(() => { + load(); + }, [load]); - const handleCounterAction = (bidId: string, action: 'accept' | 'decline') => { + const handleCounterAction = (bidId: string, action: "accept" | "decline") => { confirmRef.current = { bidId, action }; setShowConfirm(true); }; @@ -85,16 +105,16 @@ export default function BidsDashboardPage() { setShowConfirm(false); setActionLoading(ref.bidId); try { - if (ref.action === 'accept') { + if (ref.action === "accept") { await bidApi.acceptCounter(bid.shipmentId, bid.id); - toast.success('Counter offer accepted'); + toast.success("Counter offer accepted"); } else { await bidApi.declineCounter(bid.shipmentId, bid.id); - toast.success('Counter offer declined'); + toast.success("Counter offer declined"); } await load(); } catch { - toast.error('Action failed. Please try again.'); + toast.error("Action failed. Please try again."); } finally { setActionLoading(null); } @@ -104,9 +124,13 @@ export default function BidsDashboardPage() { return (
- {[...Array(3)].map((_, i) => ( -
- ))} + + + {Array.from({ length: 3 }).map((_, i) => ( + + ))} + +
); } @@ -116,11 +140,7 @@ export default function BidsDashboardPage() {

My Bids

{bids.length === 0 ? ( - - - You haven't submitted any bids yet. - - + ) : ( @@ -132,9 +152,15 @@ export default function BidsDashboardPage() { Route - Tracking - Your Price - Counter + + Tracking + + + Your Price + + + Counter + Status Expiry Actions @@ -142,11 +168,14 @@ export default function BidsDashboardPage() { {bids.map((bid) => ( - + {bid.shipment ? `${bid.shipment.origin} → ${bid.shipment.destination}` - : '—'} + : "—"} {bid.shipment ? ( @@ -156,7 +185,9 @@ export default function BidsDashboardPage() { > {bid.shipment.trackingNumber} - ) : '—'} + ) : ( + "—" + )} ${Number(bid.proposedPrice).toLocaleString()} @@ -166,11 +197,15 @@ export default function BidsDashboardPage() { ${Number(bid.counterPrice).toLocaleString()} - ) : '—'} + ) : ( + "—" + )} - - {bid.status.replace('_', ' ')} + + {bid.status.replace("_", " ")} @@ -178,12 +213,14 @@ export default function BidsDashboardPage() {
- {bid.status === 'COUNTER_OFFERED' && ( + {bid.status === "COUNTER_OFFERED" && ( <> @@ -191,7 +228,9 @@ export default function BidsDashboardPage() { size="sm" variant="outline" disabled={actionLoading === bid.id} - onClick={() => handleCounterAction(bid.id, 'decline')} + onClick={() => + handleCounterAction(bid.id, "decline") + } > Decline Counter @@ -225,14 +264,16 @@ export default function BidsDashboardPage() { - {confirmRef.current?.action === 'accept' ? 'Accept Counter Offer?' : 'Decline Counter Offer?'} + {confirmRef.current?.action === "accept" + ? "Accept Counter Offer?" + : "Decline Counter Offer?"}

- {confirmRef.current?.action === 'accept' - ? 'This will accept the counter offer and update the bid status.' - : 'This will decline the counter offer.'} + {confirmRef.current?.action === "accept" + ? "This will accept the counter offer and update the bid status." + : "This will decline the counter offer."}

+ + Refresh dashboard + +
+
+
+ ); +} diff --git a/frontend/app/(dashboard)/marketplace/page.tsx b/frontend/app/(dashboard)/marketplace/page.tsx index 9945074c..6857f039 100644 --- a/frontend/app/(dashboard)/marketplace/page.tsx +++ b/frontend/app/(dashboard)/marketplace/page.tsx @@ -1,71 +1,79 @@ -'use client'; - -import { useEffect, useState, useCallback } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { shipmentApi } from '../../../lib/api/shipment.api'; -import { ShipmentCard } from '../../../components/shipment/shipment-card'; -import { ShipmentCardSkeleton } from '../../../components/ui/skeleton'; -import { Input } from '../../../components/ui/input'; -import { Button } from '../../../components/ui/button'; -import { toast } from 'sonner'; -import type { QueryShipmentParams } from '../../../types/shipment.types'; +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { shipmentApi } from "../../../lib/api/shipment.api"; +import { ShipmentCard } from "../../../components/shipment/shipment-card"; +import { ShipmentCardSkeleton } from "../../../components/skeletons"; +import { Input } from "../../../components/ui/input"; +import { Button } from "../../../components/ui/button"; +import { EmptyMarketplace } from "../../../components/ui/empty-state"; +import { toast } from "sonner"; +import type { QueryShipmentParams } from "../../../types/shipment.types"; const CARGO_CATEGORIES = [ - 'All', - 'Electronics', - 'Furniture', - 'Food & Beverage', - 'Clothing', - 'Machinery', - 'Chemicals', - 'Automotive', - 'Medical', - 'Other', + "All", + "Electronics", + "Furniture", + "Food & Beverage", + "Clothing", + "Machinery", + "Chemicals", + "Automotive", + "Medical", + "Other", ]; -type SortOption = 'price_asc' | 'price_desc' | 'date_asc' | 'date_desc'; +type SortOption = "price_asc" | "price_desc" | "date_asc" | "date_desc"; const SORT_OPTIONS: { label: string; value: SortOption }[] = [ - { label: 'Price: Low → High', value: 'price_asc' }, - { label: 'Price: High → Low', value: 'price_desc' }, - { label: 'Newest First', value: 'date_desc' }, - { label: 'Oldest First', value: 'date_asc' }, + { label: "Price: Low → High", value: "price_asc" }, + { label: "Price: High → Low", value: "price_desc" }, + { label: "Newest First", value: "date_desc" }, + { label: "Oldest First", value: "date_asc" }, ]; export default function MarketplacePage() { - const [origin, setOrigin] = useState(''); - const [destination, setDestination] = useState(''); - const [cargoCategory, setCargoCategory] = useState('All'); - const [minPrice, setMinPrice] = useState(''); - const [maxPrice, setMaxPrice] = useState(''); - const [sort, setSort] = useState('date_desc'); + const [origin, setOrigin] = useState(""); + const [destination, setDestination] = useState(""); + const [cargoCategory, setCargoCategory] = useState("All"); + const [minPrice, setMinPrice] = useState(""); + const [maxPrice, setMaxPrice] = useState(""); + const [sort, setSort] = useState("date_desc"); const [page, setPage] = useState(1); const [filters, setFilters] = useState({ page: 1, limit: 12, }); - const { data: result, isLoading, error } = useQuery({ - queryKey: ['marketplace', filters], + const { + data: result, + isLoading, + error, + } = useQuery({ + queryKey: ["marketplace", filters], queryFn: () => shipmentApi.marketplace({ ...filters, page: filters.page }), }); useEffect(() => { - if (error) toast.error('Failed to load marketplace'); + if (error) toast.error("Failed to load marketplace"); }, [error]); - const applyFilters = useCallback((pg = 1) => { - setPage(pg); - setFilters({ - origin: origin || undefined, - destination: destination || undefined, - page: pg, - limit: 12, - cargoCategory: cargoCategory !== 'All' ? cargoCategory : undefined, - minPrice: minPrice ? Number(minPrice) : undefined, - maxPrice: maxPrice ? Number(maxPrice) : undefined, - }); - }, [origin, destination, cargoCategory, minPrice, maxPrice]); + const applyFilters = useCallback( + (pg = 1) => { + setPage(pg); + setFilters({ + origin: origin || undefined, + destination: destination || undefined, + page: pg, + limit: 12, + cargoCategory: cargoCategory !== "All" ? cargoCategory : undefined, + minPrice: minPrice ? Number(minPrice) : undefined, + maxPrice: maxPrice ? Number(maxPrice) : undefined, + }); + }, + [origin, destination, cargoCategory, minPrice, maxPrice], + ); const handleSearch = (e: React.FormEvent) => { e.preventDefault(); @@ -73,25 +81,33 @@ export default function MarketplacePage() { }; const handleClear = () => { - setOrigin(''); - setDestination(''); - setCargoCategory('All'); - setMinPrice(''); - setMaxPrice(''); - setSort('date_desc'); + setOrigin(""); + setDestination(""); + setCargoCategory("All"); + setMinPrice(""); + setMaxPrice(""); + setSort("date_desc"); setPage(1); setFilters({ page: 1, limit: 12 }); }; // Client-side sort (API may not support all sort params) - const sorted = result?.data ? [...result.data].sort((a, b) => { - if (sort === 'price_asc') return Number(a.price) - Number(b.price); - if (sort === 'price_desc') return Number(b.price) - Number(a.price); - if (sort === 'date_asc') return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); - }) : []; - - const hasFilters = origin || destination || cargoCategory !== 'All' || minPrice || maxPrice; + const sorted = result?.data + ? [...result.data].sort((a, b) => { + if (sort === "price_asc") return Number(a.price) - Number(b.price); + if (sort === "price_desc") return Number(b.price) - Number(a.price); + if (sort === "date_asc") + return ( + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + return ( + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + }) + : []; + + const hasFilters = + origin || destination || cargoCategory !== "All" || minPrice || maxPrice; return (
@@ -123,7 +139,9 @@ export default function MarketplacePage() { className="text-sm bg-background border border-border rounded-md px-3 py-2 text-foreground" > {CARGO_CATEGORIES.map((c) => ( - + ))} {SORT_OPTIONS.map((o) => ( - + ))} {hasFilters && ( - )} @@ -170,11 +195,7 @@ export default function MarketplacePage() { ))}
) : !result || sorted.length === 0 ? ( -
-

- No available shipments right now. Check back soon! -

-
+ ) : ( <>
@@ -208,7 +229,7 @@ export default function MarketplacePage() { )}

- {result.total} shipment{result.total !== 1 ? 's' : ''} available + {result.total} shipment{result.total !== 1 ? "s" : ""} available

)} diff --git a/frontend/app/(dashboard)/settings/security/page.tsx b/frontend/app/(dashboard)/settings/security/page.tsx index 8d2eab14..f6c2c0d9 100644 --- a/frontend/app/(dashboard)/settings/security/page.tsx +++ b/frontend/app/(dashboard)/settings/security/page.tsx @@ -1,108 +1,140 @@ -'use client' +"use client"; -import * as React from 'react' -import { authApi, Setup2FAResponse } from '@/lib/api/auth.api' -import { - ShieldAlert, ShieldCheck, Copy, Check, - Download, Loader2, KeyRound, Smartphone, AlertTriangle -} from 'lucide-react' -import { Button } from '@/components/ui/button' -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' -import { Badge } from '@/components/ui/badge' -import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { Checkbox } from '@/components/ui/checkbox' -import { Separator } from '@/components/ui/separator' +import * as React from "react"; +import { toast } from "sonner"; +import { authApi, Setup2FAResponse } from "@/lib/api/auth.api"; +import { + ShieldAlert, + ShieldCheck, + Copy, + Check, + Download, + Loader2, + KeyRound, + Smartphone, + AlertTriangle, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Separator } from "@/components/ui/separator"; export default function SecuritySettingsPage() { - const [is2FAEnabled, setIs2FAEnabled] = React.useState(false) // Default updated via profile check hooks - const [loading, setLoading] = React.useState(false) - const [copiedText, setCopiedText] = React.useState(false) + const [is2FAEnabled, setIs2FAEnabled] = React.useState(false); // Default updated via profile check hooks + const [loading, setLoading] = React.useState(false); + const [copiedText, setCopiedText] = React.useState(false); // Modals state engines - const [setupModalOpen, setSetupModalOpen] = React.useState(false) - const [disableModalOpen, setDisableModalOpen] = React.useState(false) + const [setupModalOpen, setSetupModalOpen] = React.useState(false); + const [disableModalOpen, setDisableModalOpen] = + React.useState(false); // Setup Wizard State Tracking - const [step, setStep] = React.useState<1 | 2 | 3>(1) - const [setupData, setSetupData] = React.useState(null) - const [otpValue, setOtpValue] = React.useState('') - const [recoveryCodes, setRecoveryCodes] = React.useState([]) - const [confirmSavedCodes, setConfirmSavedCodes] = React.useState(false) - const [inlineError, setInlineError] = React.useState(null) + const [step, setStep] = React.useState<1 | 2 | 3>(1); + const [setupData, setSetupData] = React.useState( + null, + ); + const [otpValue, setOtpValue] = React.useState(""); + const [recoveryCodes, setRecoveryCodes] = React.useState([]); + const [confirmSavedCodes, setConfirmSavedCodes] = + React.useState(false); + const [inlineError, setInlineError] = React.useState(null); // Disable Flow State Tracking - const [confirmPassword, setConfirmPassword] = React.useState('') + const [confirmPassword, setConfirmPassword] = React.useState(""); const handleOpenSetup = async () => { try { - setLoading(true) - setInlineError(null) - const data = await authApi.setup2FA() - setSetupData(data) - setStep(1) - setSetupModalOpen(true) + setLoading(true); + setInlineError(null); + const data = await authApi.setup2FA(); + setSetupData(data); + setStep(1); + setSetupModalOpen(true); } catch (err: unknown) { - alert((err as {message?: string}).message || 'Could not initialize 2FA configuration.') + toast.error( + (err as { message?: string }).message || + "Could not initialize 2FA configuration.", + ); } finally { - setLoading(false) + setLoading(false); } - } + }; const handleVerifyEnable = async (e: React.FormEvent) => { - e.preventDefault() - if (otpValue.length !== 6) return + e.preventDefault(); + if (otpValue.length !== 6) return; try { - setLoading(true) - setInlineError(null) - const data = await authApi.enable2FA(otpValue) - setRecoveryCodes(data.recoveryCodes) - setIs2FAEnabled(true) - setStep(3) + setLoading(true); + setInlineError(null); + const data = await authApi.enable2FA(otpValue); + setRecoveryCodes(data.recoveryCodes); + setIs2FAEnabled(true); + setStep(3); } catch (err: unknown) { - setInlineError((err as {message?: string}).message ?? null) + setInlineError((err as { message?: string }).message ?? null); } finally { - setLoading(false) + setLoading(false); } - } + }; const handleDisable2FA = async (e: React.FormEvent) => { - e.preventDefault() + e.preventDefault(); try { - setLoading(true) - setInlineError(null) - await authApi.disable2FA(confirmPassword) - setIs2FAEnabled(false) - setDisableModalOpen(false) - setConfirmPassword('') + setLoading(true); + setInlineError(null); + await authApi.disable2FA(confirmPassword); + setIs2FAEnabled(false); + setDisableModalOpen(false); + setConfirmPassword(""); } catch (err: unknown) { - setInlineError((err as {message?: string}).message ?? null) + setInlineError((err as { message?: string }).message ?? null); } finally { - setLoading(false) + setLoading(false); } - } + }; const copyToClipboard = (text: string) => { - navigator.clipboard.writeText(text) - setCopiedText(true) - setTimeout(() => setCopiedText(false), 2000) - } + navigator.clipboard.writeText(text); + setCopiedText(true); + setTimeout(() => setCopiedText(false), 2000); + }; const downloadRecoveryCodesTxt = () => { - const element = document.createElement("a") - const file = new Blob([recoveryCodes.join("\n")], { type: 'text/plain' }) - element.href = URL.createObjectURL(file) - element.download = "yieldladder-recovery-codes.txt" - document.body.appendChild(element) - element.click() - document.body.removeChild(element) - } + const element = document.createElement("a"); + const file = new Blob([recoveryCodes.join("\n")], { type: "text/plain" }); + element.href = URL.createObjectURL(file); + element.download = "yieldladder-recovery-codes.txt"; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + }; return (
-

Security Credentials Layout

-

Manage multi-factor verification keys and authorization checkpoints.

+

+ Security Credentials Layout +

+

+ Manage multi-factor verification keys and authorization checkpoints. +

@@ -111,9 +143,13 @@ export default function SecuritySettingsPage() {
- Two-Factor Authentication (2FA) + Two-Factor + Authentication (2FA) - Secure your ecosystem account using standard time-based tokens (TOTP). + + Secure your ecosystem account using standard time-based tokens + (TOTP). +
{is2FAEnabled ? ( @@ -121,7 +157,10 @@ export default function SecuritySettingsPage() { Active ) : ( - + Disabled )} @@ -129,30 +168,56 @@ export default function SecuritySettingsPage() { {is2FAEnabled ? ( -

Your account context is verified using a hardware or software authenticator app before granting entry credentials.

+

+ Your account context is verified using a hardware or software + authenticator app before granting entry credentials. +

) : ( -

Multi-factor verification is not configured yet. We recommend enabling 2FA to protect your transactions and balances.

+

+ Multi-factor verification is not configured yet. We recommend + enabling 2FA to protect your transactions and balances. +

)}
{is2FAEnabled ? ( - ) : ( - )} {/* Dynamic 3-Step Setup Verification Modal */} - !loading && step !== 3 && setSetupModalOpen(open)}> + + !loading && step !== 3 && setSetupModalOpen(open) + } + > Configure Authenticators - Enhance your account security using a 3-step setup framework. + + Enhance your account security using a 3-step setup framework. + {inlineError && ( @@ -163,41 +228,85 @@ export default function SecuritySettingsPage() { {step === 1 && setupData && (
-
Step 1: Scan this QR matrix using your mobile authenticator app (Google Authenticator, Duo, or 1Password).
+
+ Step 1: Scan this QR matrix using your mobile authenticator app + (Google Authenticator, Duo, or 1Password). +
- 2FA Setup QR Matrix + 2FA Setup QR Matrix
- +
- -
- +
)} {step === 2 && (
-
Step 2: Enter the 6-digit confirmation token visible inside your app.
+
+ Step 2: Enter the 6-digit confirmation token visible inside your + app. +
- setOtpValue(e.target.value.replace(/\D/g, ''))} + placeholder="000000" + value={otpValue} + onChange={(e) => + setOtpValue(e.target.value.replace(/\D/g, "")) + } className="text-center font-mono text-lg tracking-widest" />
- - +
@@ -208,33 +317,59 @@ export default function SecuritySettingsPage() {
- Warning: Keep these emergency backup recovery phrases safe. They will not be displayed again. + Warning: Keep these emergency backup recovery + phrases safe. They will not be displayed again.
- {recoveryCodes.map((code, idx) =>
{code}
)} + {recoveryCodes.map((code, idx) => ( +
+ {code} +
+ ))}
- -
- setConfirmSavedCodes(!!checked)} /> -
-
@@ -243,13 +378,19 @@ export default function SecuritySettingsPage() { {/* Danger-Zone Deactivation Confirmation Modal */} - !loading && setDisableModalOpen(open)}> + !loading && setDisableModalOpen(open)} + > Confirm Deactivation - To disable 2FA protections, confirm your primary identity profile parameters below. + + To disable 2FA protections, confirm your primary identity profile + parameters below. + {inlineError && ( @@ -260,24 +401,40 @@ export default function SecuritySettingsPage() {
- - setConfirmPassword(e.target.value)} + + setConfirmPassword(e.target.value)} />
- - +
- ) -} \ No newline at end of file + ); +} diff --git a/frontend/app/(dashboard)/shipments/page.tsx b/frontend/app/(dashboard)/shipments/page.tsx index 3e937bfa..02a41419 100644 --- a/frontend/app/(dashboard)/shipments/page.tsx +++ b/frontend/app/(dashboard)/shipments/page.tsx @@ -1,46 +1,50 @@ -'use client'; +"use client"; -import { useEffect, useState } from 'react'; -import Link from 'next/link'; -import { useAuthStore } from '../../../stores/auth.store'; -import { shipmentApi } from '../../../lib/api/shipment.api'; -import { ShipmentStatus, PaginatedShipments } from '../../../types/shipment.types'; -import { ShipmentCard } from '../../../components/shipment/shipment-card'; -import { ShipmentCardSkeleton } from '../../../components/ui/skeleton'; -import { Button } from '../../../components/ui/button'; -import { toast } from 'sonner'; -import { apiClient } from '../../../lib/api/client'; +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useAuthStore } from "../../../stores/auth.store"; +import { shipmentApi } from "../../../lib/api/shipment.api"; +import { + ShipmentStatus, + PaginatedShipments, +} from "../../../types/shipment.types"; +import { ShipmentCard } from "../../../components/shipment/shipment-card"; +import { ShipmentCardSkeleton } from "../../../components/skeletons"; +import { Button } from "../../../components/ui/button"; +import { EmptyShipments } from "../../../components/ui/empty-state"; +import { toast } from "sonner"; +import { apiClient } from "../../../lib/api/client"; -const STATUS_TABS: { label: string; value: ShipmentStatus | 'all' }[] = [ - { label: 'All', value: 'all' }, - { label: 'Pending', value: ShipmentStatus.PENDING }, - { label: 'Accepted', value: ShipmentStatus.ACCEPTED }, - { label: 'In Transit', value: ShipmentStatus.IN_TRANSIT }, - { label: 'Delivered', value: ShipmentStatus.DELIVERED }, - { label: 'Completed', value: ShipmentStatus.COMPLETED }, +const STATUS_TABS: { label: string; value: ShipmentStatus | "all" }[] = [ + { label: "All", value: "all" }, + { label: "Pending", value: ShipmentStatus.PENDING }, + { label: "Accepted", value: ShipmentStatus.ACCEPTED }, + { label: "In Transit", value: ShipmentStatus.IN_TRANSIT }, + { label: "Delivered", value: ShipmentStatus.DELIVERED }, + { label: "Completed", value: ShipmentStatus.COMPLETED }, ]; export default function ShipmentsPage() { const { user } = useAuthStore(); const [result, setResult] = useState(null); - const [activeTab, setActiveTab] = useState('all'); + const [activeTab, setActiveTab] = useState("all"); const [loading, setLoading] = useState(true); const [exporting, setExporting] = useState(false); const exportCsv = async () => { setExporting(true); try { - const blob = await apiClient('/shipments/export?format=csv', { - headers: { Accept: 'text/csv' }, + const blob = await apiClient("/shipments/export?format=csv", { + headers: { Accept: "text/csv" }, }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); + const a = document.createElement("a"); a.href = url; - a.download = 'shipments.csv'; + a.download = "shipments.csv"; a.click(); URL.revokeObjectURL(url); } catch { - toast.error('Failed to export CSV. Please try again.'); + toast.error("Failed to export CSV. Please try again."); } finally { setExporting(false); } @@ -49,14 +53,14 @@ export default function ShipmentsPage() { useEffect(() => { setLoading(true); shipmentApi - .list({ status: activeTab === 'all' ? undefined : activeTab }) + .list({ status: activeTab === "all" ? undefined : activeTab }) .then(setResult) - .catch(() => toast.error('Failed to load shipments')) + .catch(() => toast.error("Failed to load shipments")) .finally(() => setLoading(false)); }, [activeTab]); - const isShipper = user?.role === 'shipper' || user?.role === 'admin'; - const pageTitle = user?.role === 'carrier' ? 'My Jobs' : 'My Shipments'; + const isShipper = user?.role === "shipper" || user?.role === "admin"; + const pageTitle = user?.role === "carrier" ? "My Jobs" : "My Shipments"; return (
@@ -73,14 +77,30 @@ export default function ShipmentsPage() { > {exporting ? ( <> - - - + + + Exporting… ) : ( - 'Export CSV' + "Export CSV" )} {isShipper && ( @@ -99,8 +119,8 @@ export default function ShipmentsPage() { onClick={() => setActiveTab(tab.value)} className={`px-3 py-2 text-sm font-medium border-b-2 transition-colors ${ activeTab === tab.value - ? 'border-primary text-primary' - : 'border-transparent text-muted-foreground hover:text-foreground' + ? "border-primary text-primary" + : "border-transparent text-muted-foreground hover:text-foreground" }`} > {tab.label} @@ -116,18 +136,13 @@ export default function ShipmentsPage() { ))}
) : !result || result.data.length === 0 ? ( -
-

- {activeTab === 'all' - ? 'No shipments yet.' - : `No shipments with status "${activeTab}".`} -

- {isShipper && activeTab === 'all' && ( - - )} -
+ (window.location.href = "/shipments/new") + : undefined + } + /> ) : ( <>
diff --git a/frontend/app/global-error.tsx b/frontend/app/global-error.tsx new file mode 100644 index 00000000..38dfdc8f --- /dev/null +++ b/frontend/app/global-error.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { useEffect } from "react"; + +export default function GlobalError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // Log to Sentry or other monitoring service in production + console.error("[GlobalError]", error); + }, [error]); + + return ( + + +
+
+ {/* Error icon */} +
+
+ ! +
+
+ + {/* Message */} +
+

+ Critical error occurred +

+

+ {error.message || + "A critical application error occurred. Please try refreshing the page."} +

+ {error.digest && ( +

+ Error ID: {error.digest} +

+ )} +
+ + {/* Actions */} +
+ + + Go to Dashboard + +
+
+
+ + + ); +} diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx index d557fb7c..d430b41b 100644 --- a/frontend/app/layout.tsx +++ b/frontend/app/layout.tsx @@ -2,7 +2,6 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import { Toaster } from "sonner"; import { QueryProvider } from "../providers/query-provider"; -import ToastContainer from "../components/ui/ToastContainer"; import "./globals.css"; const geistSans = Geist({ @@ -40,8 +39,7 @@ export default function RootLayout({ > {children} - - + diff --git a/frontend/app/not-found.tsx b/frontend/app/not-found.tsx index 8b477e90..80ab8448 100644 --- a/frontend/app/not-found.tsx +++ b/frontend/app/not-found.tsx @@ -1,22 +1,113 @@ -import Link from 'next/link'; +import Link from "next/link"; export default function NotFound() { return (
- {/* Logo */} + {/* Truck illustration */}
-
- FF -
+
{/* Heading */}
-

404

-

Page not found

+

Page Not Found

- The page you're looking for doesn't exist or has been moved. + The page you're looking for doesn't exist or has been + moved.

@@ -29,10 +120,10 @@ export default function NotFound() { Go to Dashboard - View Shipments + Go to Home
diff --git a/frontend/components/skeletons/KpiCardSkeleton.tsx b/frontend/components/skeletons/KpiCardSkeleton.tsx new file mode 100644 index 00000000..f30b9304 --- /dev/null +++ b/frontend/components/skeletons/KpiCardSkeleton.tsx @@ -0,0 +1,12 @@ +import { Skeleton } from "../ui/skeleton"; + +/** Skeleton for a KPI card (matches dashboard metric cards) */ +export function KpiCardSkeleton() { + return ( +
+ + + +
+ ); +} diff --git a/frontend/components/skeletons/NotificationItemSkeleton.tsx b/frontend/components/skeletons/NotificationItemSkeleton.tsx new file mode 100644 index 00000000..a806ca16 --- /dev/null +++ b/frontend/components/skeletons/NotificationItemSkeleton.tsx @@ -0,0 +1,15 @@ +import { Skeleton } from "../ui/skeleton"; + +/** Skeleton for a notification item/row */ +export function NotificationItemSkeleton() { + return ( +
+ +
+ + + +
+
+ ); +} diff --git a/frontend/components/skeletons/ProfileSkeleton.tsx b/frontend/components/skeletons/ProfileSkeleton.tsx new file mode 100644 index 00000000..4cbad572 --- /dev/null +++ b/frontend/components/skeletons/ProfileSkeleton.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "../ui/skeleton"; + +/** Skeleton for a profile section (avatar + text lines) */ +export function ProfileSkeleton() { + return ( +
+ +
+ + +
+
+ ); +} diff --git a/frontend/components/skeletons/TableRowSkeleton.tsx b/frontend/components/skeletons/TableRowSkeleton.tsx new file mode 100644 index 00000000..6cfb8629 --- /dev/null +++ b/frontend/components/skeletons/TableRowSkeleton.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from "../ui/skeleton"; + +interface TableRowSkeletonProps { + columns?: number; +} + +/** Generic skeleton for a data table row with configurable columns */ +export function TableRowSkeleton({ columns = 5 }: TableRowSkeletonProps) { + return ( +
+ {Array.from({ length: columns }).map((_, i) => ( + + ))} +
+ ); +} diff --git a/frontend/components/skeletons/index.ts b/frontend/components/skeletons/index.ts new file mode 100644 index 00000000..ff59d3d9 --- /dev/null +++ b/frontend/components/skeletons/index.ts @@ -0,0 +1,12 @@ +export { + Skeleton, + ShipmentCardSkeleton, + ShipmentTableRowSkeleton, + UserTableRowSkeleton, + StatsCardSkeleton, +} from "../ui/skeleton"; + +export { KpiCardSkeleton } from "./KpiCardSkeleton"; +export { ProfileSkeleton } from "./ProfileSkeleton"; +export { NotificationItemSkeleton } from "./NotificationItemSkeleton"; +export { TableRowSkeleton } from "./TableRowSkeleton"; diff --git a/frontend/components/ui/empty-state.tsx b/frontend/components/ui/empty-state.tsx index 37deafa9..933aef6b 100644 --- a/frontend/components/ui/empty-state.tsx +++ b/frontend/components/ui/empty-state.tsx @@ -1,5 +1,6 @@ -import { ReactNode } from 'react'; -import { Button } from './button'; +import { ReactNode } from "react"; +import Link from "next/link"; +import { Button } from "./button"; interface EmptyStateProps { illustration?: ReactNode; @@ -7,11 +8,17 @@ interface EmptyStateProps { description?: string; cta?: { label: string; - onClick: () => void; + onClick?: () => void; + href?: string; }; } -export function EmptyState({ illustration, title, description, cta }: EmptyStateProps) { +export function EmptyState({ + illustration, + title, + description, + cta, +}: EmptyStateProps) { return (
{illustration && ( @@ -22,14 +29,21 @@ export function EmptyState({ illustration, title, description, cta }: EmptyState

{title}

{description && ( -

{description}

+

+ {description} +

)}
- {cta && ( - - )} + {cta && + (cta.href ? ( + + ) : cta.onClick ? ( + + ) : null)}
); } @@ -40,18 +54,61 @@ export function EmptyShipments({ onCreate }: { onCreate?: () => void }) { return (
`; } - private async loadRelations(bid: Bid, shipment: Shipment): Promise<{ carrier: User | null; shipper: User | null }> { + private async loadRelations( + bid: Bid, + shipment: Shipment, + ): Promise<{ carrier: User | null; shipper: User | null }> { const fullShipment = await this.shipmentRepo.findOne({ where: { id: shipment.id }, relations: ['shipper'], diff --git a/backend/src/bids/bids.controller.ts b/backend/src/bids/bids.controller.ts index 6ffb618c..69b3101c 100644 --- a/backend/src/bids/bids.controller.ts +++ b/backend/src/bids/bids.controller.ts @@ -62,7 +62,9 @@ export class BidsController { @HttpCode(HttpStatus.OK) @UseGuards(RolesGuard) @Roles(UserRole.SHIPPER) - @ApiOperation({ summary: 'Shipper accepts a bid — assigns carrier, rejects others' }) + @ApiOperation({ + summary: 'Shipper accepts a bid — assigns carrier, rejects others', + }) @ApiParam({ name: 'id', description: 'Shipment ID' }) @ApiParam({ name: 'bidId', description: 'Bid ID' }) acceptBid( diff --git a/backend/src/bids/bids.service.spec.ts b/backend/src/bids/bids.service.spec.ts index 3fb0ad6f..5eaf8812 100644 --- a/backend/src/bids/bids.service.spec.ts +++ b/backend/src/bids/bids.service.spec.ts @@ -10,6 +10,7 @@ import { BidsService } from './bids.service'; import { Bid, BidStatus } from './entities/bid.entity'; import { Shipment } from '../shipments/entities/shipment.entity'; import { ShipmentStatus } from '../common/enums/shipment-status.enum'; +import { User } from '../users/entities/user.entity'; const mockBidRepo = () => ({ create: jest.fn(), @@ -24,6 +25,11 @@ const mockShipmentRepo = () => ({ update: jest.fn(), }); +const mockUserRepo = () => ({ + findOne: jest.fn(), + find: jest.fn(), +}); + const mockEventEmitter = () => ({ emit: jest.fn() }); const pendingShipment = (overrides = {}): Partial => ({ @@ -58,6 +64,7 @@ describe('BidsService', () => { BidsService, { provide: getRepositoryToken(Bid), useFactory: mockBidRepo }, { provide: getRepositoryToken(Shipment), useFactory: mockShipmentRepo }, + { provide: getRepositoryToken(User), useFactory: mockUserRepo }, { provide: EventEmitter2, useFactory: mockEventEmitter }, ], }).compile(); @@ -76,7 +83,9 @@ describe('BidsService', () => { bidRepo.create.mockReturnValue(bid); bidRepo.save.mockResolvedValue(bid); - const result = await service.submitBid('ship1', 'carrier1', { proposedPrice: 100 }); + const result = await service.submitBid('ship1', 'carrier1', { + proposedPrice: 100, + }); expect(result).toMatchObject({ id: 'bid1' }); expect(bidRepo.create).toHaveBeenCalledWith( expect.objectContaining({ expiresAt: expect.any(Date) }), @@ -84,21 +93,31 @@ describe('BidsService', () => { }); it('throws if shipment not PENDING', async () => { - shipmentRepo.findOne.mockResolvedValue(pendingShipment({ status: ShipmentStatus.ACCEPTED })); - await expect(service.submitBid('ship1', 'carrier1', { proposedPrice: 100 })).rejects.toThrow(BadRequestException); + shipmentRepo.findOne.mockResolvedValue( + pendingShipment({ status: ShipmentStatus.ACCEPTED }), + ); + await expect( + service.submitBid('ship1', 'carrier1', { proposedPrice: 100 }), + ).rejects.toThrow(BadRequestException); }); it('throws if carrier already has a pending bid', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); bidRepo.findOne.mockResolvedValue(makeBid()); - await expect(service.submitBid('ship1', 'carrier1', { proposedPrice: 100 })).rejects.toThrow(BadRequestException); + await expect( + service.submitBid('ship1', 'carrier1', { proposedPrice: 100 }), + ).rejects.toThrow(BadRequestException); }); }); describe('getBids', () => { it('throws ForbiddenException if requester is not the shipper', async () => { - shipmentRepo.findOne.mockResolvedValue(pendingShipment({ shipperId: 'other' })); - await expect(service.getBids('ship1', 'shipper1')).rejects.toThrow(ForbiddenException); + shipmentRepo.findOne.mockResolvedValue( + pendingShipment({ shipperId: 'other' }), + ); + await expect(service.getBids('ship1', 'shipper1')).rejects.toThrow( + ForbiddenException, + ); }); it('returns bids with isExpired for the shipment owner', async () => { @@ -122,25 +141,36 @@ describe('BidsService', () => { const result = await service.acceptBid('ship1', 'bid1', 'shipper1'); expect(result.status).toBe(BidStatus.ACCEPTED); expect(bidRepo.update).toHaveBeenCalled(); - expect(shipmentRepo.update).toHaveBeenCalledWith('ship1', expect.objectContaining({ carrierId: 'carrier1' })); + expect(shipmentRepo.update).toHaveBeenCalledWith( + 'ship1', + expect.objectContaining({ carrierId: 'carrier1' }), + ); }); it('throws BadRequestException for expired bid', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); const expired = makeBid({ expiresAt: new Date(Date.now() - 1000) }); bidRepo.findOne.mockResolvedValue(expired); - await expect(service.acceptBid('ship1', 'bid1', 'shipper1')).rejects.toThrow(BadRequestException); + await expect( + service.acceptBid('ship1', 'bid1', 'shipper1'), + ).rejects.toThrow(BadRequestException); }); it('throws ForbiddenException if not the shipper', async () => { - shipmentRepo.findOne.mockResolvedValue(pendingShipment({ shipperId: 'other' })); - await expect(service.acceptBid('ship1', 'bid1', 'shipper1')).rejects.toThrow(ForbiddenException); + shipmentRepo.findOne.mockResolvedValue( + pendingShipment({ shipperId: 'other' }), + ); + await expect( + service.acceptBid('ship1', 'bid1', 'shipper1'), + ).rejects.toThrow(ForbiddenException); }); it('throws NotFoundException if bid not found', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); bidRepo.findOne.mockResolvedValue(null); - await expect(service.acceptBid('ship1', 'bid1', 'shipper1')).rejects.toThrow(NotFoundException); + await expect( + service.acceptBid('ship1', 'bid1', 'shipper1'), + ).rejects.toThrow(NotFoundException); }); }); @@ -149,68 +179,110 @@ describe('BidsService', () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); const bid = makeBid(); bidRepo.findOne.mockResolvedValue(bid); - bidRepo.save.mockResolvedValue({ ...bid, status: BidStatus.COUNTER_OFFERED, counterPrice: 90 }); + bidRepo.save.mockResolvedValue({ + ...bid, + status: BidStatus.COUNTER_OFFERED, + counterPrice: 90, + }); - const result = await service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 }); + const result = await service.counterBid('ship1', 'bid1', 'shipper1', { + counterPrice: 90, + }); expect(result.status).toBe(BidStatus.COUNTER_OFFERED); - expect(eventEmitter.emit).toHaveBeenCalledWith('bid.countered', expect.anything()); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'bid.countered', + expect.anything(), + ); }); it('throws if bid is expired', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - bidRepo.findOne.mockResolvedValue(makeBid({ expiresAt: new Date(Date.now() - 1000) })); - await expect(service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 })).rejects.toThrow(BadRequestException); + bidRepo.findOne.mockResolvedValue( + makeBid({ expiresAt: new Date(Date.now() - 1000) }), + ); + await expect( + service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 }), + ).rejects.toThrow(BadRequestException); }); it('throws ForbiddenException if not shipper', async () => { - shipmentRepo.findOne.mockResolvedValue(pendingShipment({ shipperId: 'other' })); - await expect(service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 })).rejects.toThrow(ForbiddenException); + shipmentRepo.findOne.mockResolvedValue( + pendingShipment({ shipperId: 'other' }), + ); + await expect( + service.counterBid('ship1', 'bid1', 'shipper1', { counterPrice: 90 }), + ).rejects.toThrow(ForbiddenException); }); }); describe('acceptCounter', () => { it('accepts the counter, assigns carrier, emits shipment.accepted', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - const bid = makeBid({ status: BidStatus.COUNTER_OFFERED, counterPrice: 90 }); + const bid = makeBid({ + status: BidStatus.COUNTER_OFFERED, + counterPrice: 90, + }); bidRepo.findOne.mockResolvedValue(bid); - bidRepo.save.mockResolvedValue({ ...bid, status: BidStatus.COUNTER_ACCEPTED }); + bidRepo.save.mockResolvedValue({ + ...bid, + status: BidStatus.COUNTER_ACCEPTED, + }); bidRepo.update.mockResolvedValue(undefined); shipmentRepo.update.mockResolvedValue(undefined); const result = await service.acceptCounter('ship1', 'bid1', 'carrier1'); expect(result.status).toBe(BidStatus.COUNTER_ACCEPTED); - expect(eventEmitter.emit).toHaveBeenCalledWith('shipment.accepted', expect.anything()); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'shipment.accepted', + expect.anything(), + ); }); it('throws ForbiddenException if not the bid owner', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - bidRepo.findOne.mockResolvedValue(makeBid({ status: BidStatus.COUNTER_OFFERED })); - await expect(service.acceptCounter('ship1', 'bid1', 'other-carrier')).rejects.toThrow(ForbiddenException); + bidRepo.findOne.mockResolvedValue( + makeBid({ status: BidStatus.COUNTER_OFFERED }), + ); + await expect( + service.acceptCounter('ship1', 'bid1', 'other-carrier'), + ).rejects.toThrow(ForbiddenException); }); it('throws BadRequestException if not in COUNTER_OFFERED status', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); bidRepo.findOne.mockResolvedValue(makeBid({ status: BidStatus.PENDING })); - await expect(service.acceptCounter('ship1', 'bid1', 'carrier1')).rejects.toThrow(BadRequestException); + await expect( + service.acceptCounter('ship1', 'bid1', 'carrier1'), + ).rejects.toThrow(BadRequestException); }); }); describe('declineCounter', () => { it('sets bid to REJECTED and emits event', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - const bid = makeBid({ status: BidStatus.COUNTER_OFFERED, counterPrice: 90 }); + const bid = makeBid({ + status: BidStatus.COUNTER_OFFERED, + counterPrice: 90, + }); bidRepo.findOne.mockResolvedValue(bid); bidRepo.save.mockResolvedValue({ ...bid, status: BidStatus.REJECTED }); const result = await service.declineCounter('ship1', 'bid1', 'carrier1'); expect(result.status).toBe(BidStatus.REJECTED); - expect(eventEmitter.emit).toHaveBeenCalledWith('bid.counter_declined', expect.anything()); + expect(eventEmitter.emit).toHaveBeenCalledWith( + 'bid.counter_declined', + expect.anything(), + ); }); it('throws ForbiddenException if not the bid owner', async () => { shipmentRepo.findOne.mockResolvedValue(pendingShipment()); - bidRepo.findOne.mockResolvedValue(makeBid({ status: BidStatus.COUNTER_OFFERED })); - await expect(service.declineCounter('ship1', 'bid1', 'other')).rejects.toThrow(ForbiddenException); + bidRepo.findOne.mockResolvedValue( + makeBid({ status: BidStatus.COUNTER_OFFERED }), + ); + await expect( + service.declineCounter('ship1', 'bid1', 'other'), + ).rejects.toThrow(ForbiddenException); }); }); }); diff --git a/backend/src/bids/bids.service.ts b/backend/src/bids/bids.service.ts index 63da1f3f..b647c93f 100644 --- a/backend/src/bids/bids.service.ts +++ b/backend/src/bids/bids.service.ts @@ -41,12 +41,16 @@ export class BidsService { where: { id: shipmentId }, relations: ['shipper'], }); - if (!shipment) throw new NotFoundException(`Shipment ${shipmentId} not found`); + if (!shipment) + throw new NotFoundException(`Shipment ${shipmentId} not found`); return shipment; } private async getBidOrFail(bidId: string, shipmentId: string): Promise { - const bid = await this.bidRepo.findOne({ where: { id: bidId, shipmentId }, relations: ['carrier'] }); + const bid = await this.bidRepo.findOne({ + where: { id: bidId, shipmentId }, + relations: ['carrier'], + }); if (!bid) throw new NotFoundException(`Bid ${bidId} not found`); return bid; } @@ -67,14 +71,18 @@ export class BidsService { ): Promise { const shipment = await this.getShipment(shipmentId); if (shipment.status !== ShipmentStatus.PENDING) { - throw new BadRequestException('Bids can only be placed on PENDING shipments'); + throw new BadRequestException( + 'Bids can only be placed on PENDING shipments', + ); } const existing = await this.bidRepo.findOne({ where: { shipmentId, carrierId, status: BidStatus.PENDING }, }); if (existing) { - throw new BadRequestException('You already have a pending bid on this shipment'); + throw new BadRequestException( + 'You already have a pending bid on this shipment', + ); } const expiresAt = new Date(); @@ -107,7 +115,10 @@ export class BidsService { return this.addIsExpired(saved); } - async getBids(shipmentId: string, requesterId: string): Promise { + async getBids( + shipmentId: string, + requesterId: string, + ): Promise { const shipment = await this.getShipment(shipmentId); if (shipment.shipperId !== requesterId) { throw new ForbiddenException('Only the shipment owner can view bids'); @@ -117,7 +128,7 @@ export class BidsService { relations: ['carrier'], order: { proposedPrice: 'ASC' }, }); - return bids.map(b => this.addIsExpired(b)); + return bids.map((b) => this.addIsExpired(b)); } async acceptBid( @@ -138,7 +149,9 @@ export class BidsService { throw new BadRequestException('Bid is no longer pending'); } if (this.isBidExpired(bid)) { - throw new BadRequestException('This bid has expired and cannot be accepted'); + throw new BadRequestException( + 'This bid has expired and cannot be accepted', + ); } bid.status = BidStatus.ACCEPTED; @@ -186,7 +199,9 @@ export class BidsService { ): Promise { const shipment = await this.getShipment(shipmentId); if (shipment.shipperId !== requesterId) { - throw new ForbiddenException('Only the shipment owner can make a counteroffer'); + throw new ForbiddenException( + 'Only the shipment owner can make a counteroffer', + ); } const bid = await this.getBidOrFail(bidId, shipmentId); @@ -194,7 +209,9 @@ export class BidsService { throw new BadRequestException('Can only counter a PENDING bid'); } if (this.isBidExpired(bid)) { - throw new BadRequestException('This bid has expired and cannot be countered'); + throw new BadRequestException( + 'This bid has expired and cannot be countered', + ); } bid.counterPrice = dto.counterPrice; @@ -237,7 +254,10 @@ export class BidsService { status: ShipmentStatus.ACCEPTED, }); - this.eventEmitter.emit('shipment.accepted', { shipment, actorId: requesterId }); + this.eventEmitter.emit('shipment.accepted', { + shipment, + actorId: requesterId, + }); return bid; } @@ -251,7 +271,9 @@ export class BidsService { const shipment = await this.getShipment(shipmentId); if (bid.carrierId !== requesterId) { - throw new ForbiddenException('Only the bid owner can decline the counter'); + throw new ForbiddenException( + 'Only the bid owner can decline the counter', + ); } if (bid.status !== BidStatus.COUNTER_OFFERED) { throw new BadRequestException('Bid is not in COUNTER_OFFERED status'); diff --git a/backend/src/bids/entities/bid.entity.ts b/backend/src/bids/entities/bid.entity.ts index c85467f9..182488c1 100644 --- a/backend/src/bids/entities/bid.entity.ts +++ b/backend/src/bids/entities/bid.entity.ts @@ -26,7 +26,11 @@ export class Bid { id: string; @Index() - @ManyToOne(() => Shipment, { eager: false, nullable: false, onDelete: 'CASCADE' }) + @ManyToOne(() => Shipment, { + eager: false, + nullable: false, + onDelete: 'CASCADE', + }) @JoinColumn({ name: 'shipment_id' }) shipment: Shipment; @@ -49,7 +53,13 @@ export class Bid { @Column({ type: 'enum', enum: BidStatus, default: BidStatus.PENDING }) status: BidStatus; - @Column({ name: 'counter_price', type: 'decimal', precision: 14, scale: 2, nullable: true }) + @Column({ + name: 'counter_price', + type: 'decimal', + precision: 14, + scale: 2, + nullable: true, + }) counterPrice: number | null; @Column({ name: 'counter_message', type: 'text', nullable: true }) diff --git a/backend/src/carriers/carrier-certifications.service.spec.ts b/backend/src/carriers/carrier-certifications.service.spec.ts index 8db52e36..2ede5bc6 100644 --- a/backend/src/carriers/carrier-certifications.service.spec.ts +++ b/backend/src/carriers/carrier-certifications.service.spec.ts @@ -2,7 +2,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { NotFoundException, ForbiddenException } from '@nestjs/common'; import { CarrierCertificationsService } from './carrier-certifications.service'; -import { CarrierCertification, CertificationType } from './entities/carrier-certification.entity'; +import { + CarrierCertification, + CertificationType, +} from './entities/carrier-certification.entity'; import { CreateCarrierCertificationDto, UpdateCertificationVerificationDto, diff --git a/backend/src/carriers/carrier-earnings.service.ts b/backend/src/carriers/carrier-earnings.service.ts index 7f0db2e3..4e7715a4 100644 --- a/backend/src/carriers/carrier-earnings.service.ts +++ b/backend/src/carriers/carrier-earnings.service.ts @@ -29,7 +29,10 @@ export class CarrierEarningsService { select: ['id', 'price', 'actualDeliveryDate'], }); - const lifetimeEarnings = completed.reduce((sum, s) => sum + Number(s.price), 0); + const lifetimeEarnings = completed.reduce( + (sum, s) => sum + Number(s.price), + 0, + ); const now = new Date(); const currentMonthKey = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`; @@ -54,9 +57,13 @@ export class CarrierEarningsService { } } - const monthlyBreakdown: MonthlyBreakdown[] = Array.from(buckets.entries()).map( - ([month, { earnings, count }]) => ({ month, earnings, completedShipments: count }), - ); + const monthlyBreakdown: MonthlyBreakdown[] = Array.from( + buckets.entries(), + ).map(([month, { earnings, count }]) => ({ + month, + earnings, + completedShipments: count, + })); const currentMonthEarnings = buckets.get(currentMonthKey)?.earnings ?? 0; diff --git a/backend/src/carriers/carriers.controller.ts b/backend/src/carriers/carriers.controller.ts index 28231042..71fd11de 100644 --- a/backend/src/carriers/carriers.controller.ts +++ b/backend/src/carriers/carriers.controller.ts @@ -30,7 +30,9 @@ export class CarriersController { @Get('me/metrics') @UseGuards(RolesGuard) @Roles(UserRole.CARRIER) - @ApiOperation({ summary: 'Get performance metrics for the authenticated carrier' }) + @ApiOperation({ + summary: 'Get performance metrics for the authenticated carrier', + }) getMyMetrics(@CurrentUser() user: User) { return this.carriersService.getMyMetrics(user.id); } @@ -58,7 +60,10 @@ export class CarriersController { @Get(':id/certifications') @UseGuards(RolesGuard) - @ApiOperation({ summary: 'Get certifications for a carrier (visible to all authenticated users)' }) + @ApiOperation({ + summary: + 'Get certifications for a carrier (visible to all authenticated users)', + }) getCarrierCertifications(@Param('id', ParseUUIDPipe) id: string) { return this.certificationsService.findByCarrierId(id); } diff --git a/backend/src/carriers/carriers.service.ts b/backend/src/carriers/carriers.service.ts index 8bdcf795..b5882310 100644 --- a/backend/src/carriers/carriers.service.ts +++ b/backend/src/carriers/carriers.service.ts @@ -14,14 +14,27 @@ export class CarriersService { async getMyMetrics(carrierId: string) { const shipments = await this.shipmentRepo.find({ where: { carrierId }, - select: ['id', 'status', 'price', 'currency', 'estimatedDeliveryDate', 'actualDeliveryDate'], + select: [ + 'id', + 'status', + 'price', + 'currency', + 'estimatedDeliveryDate', + 'actualDeliveryDate', + ], }); - const completed = shipments.filter((s) => s.status === ShipmentStatus.COMPLETED); + const completed = shipments.filter( + (s) => s.status === ShipmentStatus.COMPLETED, + ); const delivered = shipments.filter( - (s) => s.status === ShipmentStatus.DELIVERED || s.status === ShipmentStatus.COMPLETED, + (s) => + s.status === ShipmentStatus.DELIVERED || + s.status === ShipmentStatus.COMPLETED, + ); + const cancelled = shipments.filter( + (s) => s.status === ShipmentStatus.CANCELLED, ); - const cancelled = shipments.filter((s) => s.status === ShipmentStatus.CANCELLED); const totalAccepted = shipments.filter( (s) => s.status !== ShipmentStatus.PENDING, @@ -34,11 +47,16 @@ export class CarriersService { new Date(s.actualDeliveryDate) <= new Date(s.estimatedDeliveryDate), ).length; - const onTimeRate = delivered.length > 0 ? onTimeDeliveries / delivered.length : 0; + const onTimeRate = + delivered.length > 0 ? onTimeDeliveries / delivered.length : 0; - const totalEarnings = completed.reduce((sum, s) => sum + Number(s.price), 0); + const totalEarnings = completed.reduce( + (sum, s) => sum + Number(s.price), + 0, + ); - const cancellationRate = totalAccepted > 0 ? cancelled.length / totalAccepted : 0; + const cancellationRate = + totalAccepted > 0 ? cancelled.length / totalAccepted : 0; return { totalAccepted, diff --git a/backend/src/cloudinary/cloudinary.service.ts b/backend/src/cloudinary/cloudinary.service.ts index 44adeb30..04ba81ca 100644 --- a/backend/src/cloudinary/cloudinary.service.ts +++ b/backend/src/cloudinary/cloudinary.service.ts @@ -21,7 +21,10 @@ export class CloudinaryService { const stream = cloudinary.uploader.upload_stream( { folder, public_id: publicId, resource_type: 'auto' }, (error: unknown, result: UploadApiResponse) => { - if (error) return reject(error); + if (error) + return reject( + new Error('Cloudinary upload failed', { cause: error }), + ); resolve(result); }, ); diff --git a/backend/src/common/cursor-pagination.util.ts b/backend/src/common/cursor-pagination.util.ts index 964522d3..13213ed9 100644 --- a/backend/src/common/cursor-pagination.util.ts +++ b/backend/src/common/cursor-pagination.util.ts @@ -1,7 +1,7 @@ import { Repository, FindOptionsWhere, LessThan } from 'typeorm'; export interface CursorPageOptions { - cursor?: string; // opaque base64 cursor from previous response + cursor?: string; // opaque base64 cursor from previous response limit?: number; // Legacy offset fallback page?: number; @@ -26,7 +26,9 @@ export function encodeCursor(createdAt: Date, id: string): string { export function decodeCursor(cursor: string): CursorPayload { try { - return JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as CursorPayload; + return JSON.parse( + Buffer.from(cursor, 'base64url').toString('utf8'), + ) as CursorPayload; } catch { throw new Error('Invalid pagination cursor'); } @@ -40,7 +42,10 @@ export async function cursorPaginate( const limit = Math.min(options.limit ?? 20, 100); // Offset fallback for backwards compatibility - if (!options.cursor && (options.page !== undefined || options.pageSize !== undefined)) { + if ( + !options.cursor && + (options.page !== undefined || options.pageSize !== undefined) + ) { const page = options.page ?? 1; const pageSize = options.pageSize ?? limit; const [data, total] = await repo.findAndCount({ diff --git a/backend/src/documents/documents.service.ts b/backend/src/documents/documents.service.ts index 5a6ccc61..909e4d98 100644 --- a/backend/src/documents/documents.service.ts +++ b/backend/src/documents/documents.service.ts @@ -94,17 +94,12 @@ export class DocumentsService { const saved = await this.documentRepo.save(doc); - this.enqueueIpfsPin(saved.id, saved.storedUrl!).catch(() => { - // Silently ignore — job will be retried or handled by worker - }); + this.enqueueIpfsPin(saved.id, saved.storedUrl!); return saved; } - private async enqueueIpfsPin( - documentId: string, - cloudinaryUrl: string, - ): Promise { + private enqueueIpfsPin(documentId: string, cloudinaryUrl: string): void { // TODO: Replace with BullMQ queue.add('ipfs-pin', { documentId, cloudinaryUrl }) console.log( `[IPFS Pin] Enqueued: documentId=${documentId}, url=${cloudinaryUrl}`, diff --git a/backend/src/messaging/dto/create-conversation.dto.ts b/backend/src/messaging/dto/create-conversation.dto.ts index efbebd0e..2712b937 100644 --- a/backend/src/messaging/dto/create-conversation.dto.ts +++ b/backend/src/messaging/dto/create-conversation.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsNotEmpty, IsUUID } from 'class-validator'; +import { IsNotEmpty, IsUUID } from 'class-validator'; export class CreateConversationDto { @IsUUID() diff --git a/backend/src/messaging/messaging.controller.ts b/backend/src/messaging/messaging.controller.ts index 90371a0a..73816e15 100644 --- a/backend/src/messaging/messaging.controller.ts +++ b/backend/src/messaging/messaging.controller.ts @@ -29,10 +29,7 @@ export class MessagingController { } @Post() - findOrCreate( - @Body() dto: CreateConversationDto, - @CurrentUser() user: User, - ) { + findOrCreate(@Body() dto: CreateConversationDto, @CurrentUser() user: User) { return this.messagingService.findOrCreateConversation(dto, user); } diff --git a/backend/src/messaging/messaging.service.ts b/backend/src/messaging/messaging.service.ts index 0e70593a..1326d004 100644 --- a/backend/src/messaging/messaging.service.ts +++ b/backend/src/messaging/messaging.service.ts @@ -84,10 +84,7 @@ export class MessagingService { }[] > { const conversations = await this.conversationRepo.find({ - where: [ - { shipperId: currentUser.id }, - { carrierId: currentUser.id }, - ], + where: [{ shipperId: currentUser.id }, { carrierId: currentUser.id }], order: { lastMessageAt: 'DESC' }, }); @@ -112,9 +109,7 @@ export class MessagingService { return { id: conv.id, shipmentId: conv.shipmentId, - lastMessage: lastMsg - ? lastMsg.body.substring(0, 80) - : null, + lastMessage: lastMsg ? lastMsg.body.substring(0, 80) : null, lastMessageAt: conv.lastMessageAt, unreadCount, }; diff --git a/backend/src/migrations/1750678212610-BidCounterOfferAndExpiry.ts b/backend/src/migrations/1750678212610-BidCounterOfferAndExpiry.ts index c1a35c9f..4c0698cc 100644 --- a/backend/src/migrations/1750678212610-BidCounterOfferAndExpiry.ts +++ b/backend/src/migrations/1750678212610-BidCounterOfferAndExpiry.ts @@ -1,24 +1,42 @@ import { MigrationInterface, QueryRunner } from 'typeorm'; -export class BidCounterOfferAndExpiry1750678212610 implements MigrationInterface { +export class BidCounterOfferAndExpiry1750678212610 + implements MigrationInterface +{ name = 'BidCounterOfferAndExpiry1750678212610'; public async up(queryRunner: QueryRunner): Promise { // Extend the BidStatus enum with new values - await queryRunner.query(`ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_OFFERED'`); - await queryRunner.query(`ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_ACCEPTED'`); - await queryRunner.query(`ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_REJECTED'`); - await queryRunner.query(`ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'EXPIRED'`); + await queryRunner.query( + `ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_OFFERED'`, + ); + await queryRunner.query( + `ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_ACCEPTED'`, + ); + await queryRunner.query( + `ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'COUNTER_REJECTED'`, + ); + await queryRunner.query( + `ALTER TYPE "public"."bids_status_enum" ADD VALUE IF NOT EXISTS 'EXPIRED'`, + ); // Add new columns - await queryRunner.query(`ALTER TABLE "bids" ADD "counter_price" numeric(14,2)`); + await queryRunner.query( + `ALTER TABLE "bids" ADD "counter_price" numeric(14,2)`, + ); await queryRunner.query(`ALTER TABLE "bids" ADD "counter_message" text`); - await queryRunner.query(`ALTER TABLE "bids" ADD "expires_at" TIMESTAMP WITH TIME ZONE`); - await queryRunner.query(`ALTER TABLE "bids" ADD "counter_offered_at" TIMESTAMP WITH TIME ZONE`); + await queryRunner.query( + `ALTER TABLE "bids" ADD "expires_at" TIMESTAMP WITH TIME ZONE`, + ); + await queryRunner.query( + `ALTER TABLE "bids" ADD "counter_offered_at" TIMESTAMP WITH TIME ZONE`, + ); } public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "bids" DROP COLUMN "counter_offered_at"`); + await queryRunner.query( + `ALTER TABLE "bids" DROP COLUMN "counter_offered_at"`, + ); await queryRunner.query(`ALTER TABLE "bids" DROP COLUMN "expires_at"`); await queryRunner.query(`ALTER TABLE "bids" DROP COLUMN "counter_message"`); await queryRunner.query(`ALTER TABLE "bids" DROP COLUMN "counter_price"`); diff --git a/backend/src/notification-preferences/notification-preferences.controller.ts b/backend/src/notification-preferences/notification-preferences.controller.ts index 1e81bad8..69c30725 100644 --- a/backend/src/notification-preferences/notification-preferences.controller.ts +++ b/backend/src/notification-preferences/notification-preferences.controller.ts @@ -12,14 +12,19 @@ export class NotificationPreferencesController { constructor(private readonly prefsService: NotificationPreferencesService) {} @Get() - @ApiOperation({ summary: 'Get notification preferences for the current user' }) + @ApiOperation({ + summary: 'Get notification preferences for the current user', + }) get(@CurrentUser() user: User) { return this.prefsService.getOrCreate(user.id); } @Patch() @ApiOperation({ summary: 'Update notification preferences' }) - update(@CurrentUser() user: User, @Body() dto: UpdateNotificationPreferencesDto) { + update( + @CurrentUser() user: User, + @Body() dto: UpdateNotificationPreferencesDto, + ) { return this.prefsService.update(user.id, dto); } } diff --git a/backend/src/notification-preferences/notification-preferences.service.spec.ts b/backend/src/notification-preferences/notification-preferences.service.spec.ts index 760b8044..c0a2e0d0 100644 --- a/backend/src/notification-preferences/notification-preferences.service.spec.ts +++ b/backend/src/notification-preferences/notification-preferences.service.spec.ts @@ -79,7 +79,10 @@ describe('NotificationPreferencesService', () => { }); it('returns false for disabled preference', async () => { - repo.findOne.mockResolvedValue({ ...defaultPrefs(), shipmentDisputed: false }); + repo.findOne.mockResolvedValue({ + ...defaultPrefs(), + shipmentDisputed: false, + }); const result = await service.isEnabled('user1', 'shipmentDisputed'); expect(result).toBe(false); }); diff --git a/backend/src/notification-preferences/notification-preferences.service.ts b/backend/src/notification-preferences/notification-preferences.service.ts index c40312d9..08212f25 100644 --- a/backend/src/notification-preferences/notification-preferences.service.ts +++ b/backend/src/notification-preferences/notification-preferences.service.ts @@ -31,9 +31,12 @@ export class NotificationPreferencesService { async isEnabled( userId: string, - key: keyof Omit, + key: keyof Omit< + NotificationPreferences, + 'id' | 'userId' | 'user' | 'updatedAt' + >, ): Promise { const prefs = await this.getOrCreate(userId); - return prefs[key] as boolean; + return prefs[key]; } } diff --git a/backend/src/notifications/dto/notification-query.dto.ts b/backend/src/notifications/dto/notification-query.dto.ts index d8dece7d..84a3f48a 100644 --- a/backend/src/notifications/dto/notification-query.dto.ts +++ b/backend/src/notifications/dto/notification-query.dto.ts @@ -1,4 +1,10 @@ -import { IsOptional, IsEnum, IsPositive, IsBoolean, Max } from 'class-validator'; +import { + IsOptional, + IsEnum, + IsPositive, + IsBoolean, + Max, +} from 'class-validator'; import { Type, Transform } from 'class-transformer'; import { ApiPropertyOptional } from '@nestjs/swagger'; import { NotificationType } from '../entities/notification.entity'; @@ -27,4 +33,4 @@ export class NotificationQueryDto { @Transform(({ value }) => value === 'true' || value === true) @IsBoolean() isRead?: boolean; -} \ No newline at end of file +} diff --git a/backend/src/notifications/entities/notification.entity.ts b/backend/src/notifications/entities/notification.entity.ts index a2b78851..0ca3d3a5 100644 --- a/backend/src/notifications/entities/notification.entity.ts +++ b/backend/src/notifications/entities/notification.entity.ts @@ -35,7 +35,11 @@ export class Notification { @JoinColumn({ name: 'userId' }) user: User; - @Column({ type: 'enum', enum: NotificationType, default: NotificationType.GENERAL }) + @Column({ + type: 'enum', + enum: NotificationType, + default: NotificationType.GENERAL, + }) type: NotificationType; @Column({ type: 'varchar', length: 255 }) @@ -52,4 +56,4 @@ export class Notification { @CreateDateColumn({ name: 'created_at' }) createdAt: Date; -} \ No newline at end of file +} diff --git a/backend/src/notifications/notification-inbox.controller.ts b/backend/src/notifications/notification-inbox.controller.ts index 9e04ecb6..ff70e488 100644 --- a/backend/src/notifications/notification-inbox.controller.ts +++ b/backend/src/notifications/notification-inbox.controller.ts @@ -25,8 +25,16 @@ export class NotificationInboxController { @Get() @ApiOperation({ summary: 'Fetch my notification inbox (paginated)' }) - async getInbox(@CurrentUser() user: User, @Query() query: NotificationQueryDto) { - return this.inboxService.findAll(user.id, query.page, query.limit, query.isRead); + async getInbox( + @CurrentUser() user: User, + @Query() query: NotificationQueryDto, + ) { + return this.inboxService.findAll( + user.id, + query.page, + query.limit, + query.isRead, + ); } @Get('unread-count') @@ -54,4 +62,4 @@ export class NotificationInboxController { await this.inboxService.markAllRead(user.id); return { message: 'All notifications marked as read' }; } -} \ No newline at end of file +} diff --git a/backend/src/notifications/notification-inbox.service.ts b/backend/src/notifications/notification-inbox.service.ts index 3c5b4d30..0ca9a9fd 100644 --- a/backend/src/notifications/notification-inbox.service.ts +++ b/backend/src/notifications/notification-inbox.service.ts @@ -33,7 +33,14 @@ export class NotificationInboxService { take: limit, }); const unread = await this.repo.count({ where: { userId, isRead: false } }); - return { items, total, unread, page, limit, totalPages: Math.ceil(total / limit) }; + return { + items, + total, + unread, + page, + limit, + totalPages: Math.ceil(total / limit), + }; } async markRead(id: string, userId: string): Promise { @@ -50,4 +57,4 @@ export class NotificationInboxService { async unreadCount(userId: string): Promise { return this.repo.count({ where: { userId, isRead: false } }); } -} \ No newline at end of file +} diff --git a/backend/src/notifications/notifications.gateway.ts b/backend/src/notifications/notifications.gateway.ts index 207746c9..cab1ed93 100644 --- a/backend/src/notifications/notifications.gateway.ts +++ b/backend/src/notifications/notifications.gateway.ts @@ -21,10 +21,7 @@ import { SHIPMENT_CREATED, ShipmentEvent, } from '../shipments/events/shipment.events'; -import { - MESSAGE_NEW, - MessageNewEvent, -} from '../messaging/messaging.service'; +import { MESSAGE_NEW, MessageNewEvent } from '../messaging/messaging.service'; import { JwtPayload } from '../auth/strategies/jwt.strategy'; /** Room prefix for per-user rooms */ diff --git a/backend/src/notifications/notifications.module.ts b/backend/src/notifications/notifications.module.ts index f2532896..209b14c0 100644 --- a/backend/src/notifications/notifications.module.ts +++ b/backend/src/notifications/notifications.module.ts @@ -24,7 +24,11 @@ import { Notification } from './entities/notification.entity'; }), ], controllers: [NotificationInboxController], - providers: [NotificationsService, NotificationsGateway, NotificationInboxService], + providers: [ + NotificationsService, + NotificationsGateway, + NotificationInboxService, + ], exports: [NotificationInboxService], }) -export class NotificationsModule {} \ No newline at end of file +export class NotificationsModule {} diff --git a/backend/src/push/push.module.ts b/backend/src/push/push.module.ts index 2eb7dce5..7707a924 100644 --- a/backend/src/push/push.module.ts +++ b/backend/src/push/push.module.ts @@ -7,4 +7,4 @@ import { PushService } from './push.service'; providers: [PushService], exports: [PushService], }) -export class PushModule {} \ No newline at end of file +export class PushModule {} diff --git a/backend/src/push/push.service.ts b/backend/src/push/push.service.ts index 6b76b01e..dd866ff6 100644 --- a/backend/src/push/push.service.ts +++ b/backend/src/push/push.service.ts @@ -29,4 +29,4 @@ export class PushService { this.logger.warn('Failed to send push notification: ' + msg); } } -} \ No newline at end of file +} diff --git a/backend/src/reviews/reviews.controller.ts b/backend/src/reviews/reviews.controller.ts index 5ab730e3..43a50419 100644 --- a/backend/src/reviews/reviews.controller.ts +++ b/backend/src/reviews/reviews.controller.ts @@ -1,4 +1,11 @@ -import { Body, Controller, Get, Param, ParseUUIDPipe, Post } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ReviewsService } from './reviews.service'; import { CreateReviewDto } from './dto/create-review.dto'; @@ -34,4 +41,3 @@ export class UserRatingController { return this.reviewsService.getAverageRating(id); } } - diff --git a/backend/src/reviews/reviews.service.spec.ts b/backend/src/reviews/reviews.service.spec.ts index c891a0df..f5c16948 100644 --- a/backend/src/reviews/reviews.service.spec.ts +++ b/backend/src/reviews/reviews.service.spec.ts @@ -1,6 +1,10 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { BadRequestException, ConflictException, ForbiddenException } from '@nestjs/common'; +import { + BadRequestException, + ConflictException, + ForbiddenException, +} from '@nestjs/common'; import { ReviewsService } from './reviews.service'; import { Review } from './entities/review.entity'; import { Shipment } from '../shipments/entities/shipment.entity'; @@ -26,6 +30,9 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: undefined as any, + recoveryCodes: [], ...overrides, }; } @@ -61,7 +68,12 @@ function makeShipment(overrides: Partial = {}): Shipment { describe('ReviewsService', () => { let service: ReviewsService; - let reviewRepo: { findOne: jest.Mock; create: jest.Mock; save: jest.Mock; createQueryBuilder: jest.Mock }; + let reviewRepo: { + findOne: jest.Mock; + create: jest.Mock; + save: jest.Mock; + createQueryBuilder: jest.Mock; + }; let shipmentRepo: { findOne: jest.Mock }; beforeEach(async () => { @@ -99,28 +111,40 @@ describe('ReviewsService', () => { expect(result).toBe(review); expect(reviewRepo.create).toHaveBeenCalledWith( - expect.objectContaining({ reviewerId: 'shipper-1', revieweeId: 'carrier-1', rating: 5 }), + expect.objectContaining({ + reviewerId: 'shipper-1', + revieweeId: 'carrier-1', + rating: 5, + }), ); }); it('throws BadRequestException if shipment not COMPLETED', async () => { - shipmentRepo.findOne.mockResolvedValue(makeShipment({ status: ShipmentStatus.DELIVERED })); + shipmentRepo.findOne.mockResolvedValue( + makeShipment({ status: ShipmentStatus.DELIVERED }), + ); - await expect(service.create('ship-1', makeUser(), { rating: 4 })).rejects.toThrow(BadRequestException); + await expect( + service.create('ship-1', makeUser(), { rating: 4 }), + ).rejects.toThrow(BadRequestException); }); it('throws ForbiddenException if reviewer is not a party', async () => { shipmentRepo.findOne.mockResolvedValue(makeShipment()); const outsider = makeUser({ id: 'outsider-1' }); - await expect(service.create('ship-1', outsider, { rating: 3 })).rejects.toThrow(ForbiddenException); + await expect( + service.create('ship-1', outsider, { rating: 3 }), + ).rejects.toThrow(ForbiddenException); }); it('throws ConflictException on duplicate review', async () => { shipmentRepo.findOne.mockResolvedValue(makeShipment()); reviewRepo.findOne.mockResolvedValue({ id: 'existing-review' }); - await expect(service.create('ship-1', makeUser(), { rating: 5 })).rejects.toThrow(ConflictException); + await expect( + service.create('ship-1', makeUser(), { rating: 5 }), + ).rejects.toThrow(ConflictException); }); }); diff --git a/backend/src/reviews/reviews.service.ts b/backend/src/reviews/reviews.service.ts index cc1d5bac..b1435a1b 100644 --- a/backend/src/reviews/reviews.service.ts +++ b/backend/src/reviews/reviews.service.ts @@ -21,24 +21,35 @@ export class ReviewsService { private readonly shipmentRepo: Repository, ) {} - async create(shipmentId: string, reviewer: User, dto: CreateReviewDto): Promise { - const shipment = await this.shipmentRepo.findOne({ where: { id: shipmentId } }); + async create( + shipmentId: string, + reviewer: User, + dto: CreateReviewDto, + ): Promise { + const shipment = await this.shipmentRepo.findOne({ + where: { id: shipmentId }, + }); if (!shipment) throw new BadRequestException('Shipment not found'); if (shipment.status !== ShipmentStatus.COMPLETED) { - throw new BadRequestException('Reviews can only be left for completed shipments'); + throw new BadRequestException( + 'Reviews can only be left for completed shipments', + ); } const isShipper = shipment.shipperId === reviewer.id; const isCarrier = shipment.carrierId === reviewer.id; if (!isShipper && !isCarrier) { - throw new ForbiddenException('Only parties to the shipment can leave a review'); + throw new ForbiddenException( + 'Only parties to the shipment can leave a review', + ); } const existing = await this.reviewRepo.findOne({ where: { shipmentId, reviewerId: reviewer.id }, }); - if (existing) throw new ConflictException('You have already reviewed this shipment'); + if (existing) + throw new ConflictException('You have already reviewed this shipment'); // Shipper reviews carrier, carrier reviews shipper const revieweeId = isShipper ? shipment.carrierId! : shipment.shipperId; @@ -54,7 +65,9 @@ export class ReviewsService { return this.reviewRepo.save(review); } - async getAverageRating(userId: string): Promise<{ averageRating: number; totalReviews: number }> { + async getAverageRating( + userId: string, + ): Promise<{ averageRating: number; totalReviews: number }> { const result = await this.reviewRepo .createQueryBuilder('r') .where('r.reviewee_id = :userId', { userId }) @@ -63,7 +76,9 @@ export class ReviewsService { .getRawOne<{ avg: string | null; count: string }>(); return { - averageRating: result?.avg ? Math.round(Number(result.avg) * 100) / 100 : 0, + averageRating: result?.avg + ? Math.round(Number(result.avg) * 100) / 100 + : 0, totalReviews: Number(result?.count ?? 0), }; } diff --git a/backend/src/shipments/cancellation-fee.service.ts b/backend/src/shipments/cancellation-fee.service.ts index da95984d..c6069f95 100644 --- a/backend/src/shipments/cancellation-fee.service.ts +++ b/backend/src/shipments/cancellation-fee.service.ts @@ -32,18 +32,28 @@ export class CancellationFeeService { calculateFee(price: number, status: ShipmentStatus): number { const rate = FEE_TIERS[status] ?? null; - if (rate === null) throw new BadRequestException(`Shipment in status "${status}" cannot be cancelled`); + if (rate === null) + throw new BadRequestException( + `Shipment in status "${status}" cannot be cancelled`, + ); return Math.round(price * rate * 100) / 100; } async cancelShipment(shipmentId: string): Promise { - const shipment = await this.shipmentRepo.findOneOrFail({ where: { id: shipmentId } }); + const shipment = await this.shipmentRepo.findOneOrFail({ + where: { id: shipmentId }, + }); if (!CANCELLABLE.has(shipment.status)) { - throw new BadRequestException(`Cannot cancel a shipment with status "${shipment.status}"`); + throw new BadRequestException( + `Cannot cancel a shipment with status "${shipment.status}"`, + ); } - const cancellationFee = this.calculateFee(Number(shipment.price), shipment.status); + const cancellationFee = this.calculateFee( + Number(shipment.price), + shipment.status, + ); await this.shipmentRepo.update(shipmentId, { status: ShipmentStatus.CANCELLED, diff --git a/backend/src/shipments/dispute-evidence.service.ts b/backend/src/shipments/dispute-evidence.service.ts index 21d11f9b..1b4ed91e 100644 --- a/backend/src/shipments/dispute-evidence.service.ts +++ b/backend/src/shipments/dispute-evidence.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; export interface DisputeEvidence { id: string; @@ -38,7 +42,11 @@ export class DisputeEvidenceService { } } - submit(shipmentId: string, userId: string, dto: SubmitEvidenceDto): DisputeEvidence { + submit( + shipmentId: string, + userId: string, + dto: SubmitEvidenceDto, + ): DisputeEvidence { const shipment = this.getShipment(shipmentId); this.assertAccess(shipment, userId); @@ -56,7 +64,11 @@ export class DisputeEvidenceService { return record; } - findAll(shipmentId: string, userId: string, isAdmin: boolean): DisputeEvidence[] { + findAll( + shipmentId: string, + userId: string, + isAdmin: boolean, + ): DisputeEvidence[] { const shipment = this.getShipment(shipmentId); if (!isAdmin) this.assertAccess(shipment, userId); return this.evidence.get(shipmentId) ?? []; diff --git a/backend/src/shipments/dto/analytics-query.dto.ts b/backend/src/shipments/dto/analytics-query.dto.ts index 65f5e9b5..60e85765 100644 --- a/backend/src/shipments/dto/analytics-query.dto.ts +++ b/backend/src/shipments/dto/analytics-query.dto.ts @@ -2,12 +2,18 @@ import { IsOptional, IsDateString } from 'class-validator'; import { ApiPropertyOptional } from '@nestjs/swagger'; export class AnalyticsQueryDto { - @ApiPropertyOptional({ description: 'Start date (ISO 8601)', example: '2024-01-01' }) + @ApiPropertyOptional({ + description: 'Start date (ISO 8601)', + example: '2024-01-01', + }) @IsOptional() @IsDateString() from?: string; - @ApiPropertyOptional({ description: 'End date (ISO 8601)', example: '2024-12-31' }) + @ApiPropertyOptional({ + description: 'End date (ISO 8601)', + example: '2024-12-31', + }) @IsOptional() @IsDateString() to?: string; diff --git a/backend/src/shipments/dto/batch-create-shipments.dto.ts b/backend/src/shipments/dto/batch-create-shipments.dto.ts index e31ae1ba..cd9fa9ea 100644 --- a/backend/src/shipments/dto/batch-create-shipments.dto.ts +++ b/backend/src/shipments/dto/batch-create-shipments.dto.ts @@ -1,4 +1,9 @@ -import { IsArray, ArrayMinSize, ArrayMaxSize, ValidateNested } from 'class-validator'; +import { + IsArray, + ArrayMinSize, + ArrayMaxSize, + ValidateNested, +} from 'class-validator'; import { Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; import { CreateShipmentDto } from './create-shipment.dto'; diff --git a/backend/src/shipments/dto/create-shipment.dto.ts b/backend/src/shipments/dto/create-shipment.dto.ts index 5016a1a5..a808e482 100644 --- a/backend/src/shipments/dto/create-shipment.dto.ts +++ b/backend/src/shipments/dto/create-shipment.dto.ts @@ -36,7 +36,10 @@ export class CreateShipmentDto { @MaxLength(2000) cargoDescription: string; - @ApiPropertyOptional({ enum: CargoCategory, example: CargoCategory.ELECTRONICS }) + @ApiPropertyOptional({ + enum: CargoCategory, + example: CargoCategory.ELECTRONICS, + }) @IsOptional() @IsEnum(CargoCategory) cargoCategory?: CargoCategory; diff --git a/backend/src/shipments/eta.service.ts b/backend/src/shipments/eta.service.ts index bb90613a..3eda896f 100644 --- a/backend/src/shipments/eta.service.ts +++ b/backend/src/shipments/eta.service.ts @@ -24,7 +24,8 @@ const ZONE_MAP: Record = { function resolveZone(location: string): string { const l = location.toUpperCase(); - if (/\b(US|CA|MX|BR|AR)\b/.test(l)) return l.includes('CA') ? 'CA' : l.includes('MX') ? 'MX' : 'US'; + if (/\b(US|CA|MX|BR|AR)\b/.test(l)) + return l.includes('CA') ? 'CA' : l.includes('MX') ? 'MX' : 'US'; if (/\b(UK|DE|FR|IT|ES|NL|PL|SE)\b/.test(l)) return 'EU'; if (/\b(CN|JP|KR|IN|SG|TH|VN)\b/.test(l)) return 'AS'; return 'US'; diff --git a/backend/src/shipments/shipment-template.service.ts b/backend/src/shipments/shipment-template.service.ts index 39008870..c62998fe 100644 --- a/backend/src/shipments/shipment-template.service.ts +++ b/backend/src/shipments/shipment-template.service.ts @@ -1,4 +1,8 @@ -import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { + Injectable, + NotFoundException, + ForbiddenException, +} from '@nestjs/common'; export interface ShipmentTemplate { id: string; @@ -50,8 +54,16 @@ export class ShipmentTemplateService { this.templates.delete(id); } - buildShipmentFromTemplate(id: string, userId: string): Omit { - const { name: _n, id: _id, userId: _u, ...shipmentData } = this.findOne(id, userId); + buildShipmentFromTemplate( + id: string, + userId: string, + ): Omit { + const { + name: _n, + id: _id, + userId: _u, + ...shipmentData + } = this.findOne(id, userId); return shipmentData; } } diff --git a/backend/src/shipments/shipments.analytics.spec.ts b/backend/src/shipments/shipments.analytics.spec.ts index 2abdda08..0294e72e 100644 --- a/backend/src/shipments/shipments.analytics.spec.ts +++ b/backend/src/shipments/shipments.analytics.spec.ts @@ -26,6 +26,9 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: undefined as any, + recoveryCodes: [], ...overrides, }; } diff --git a/backend/src/shipments/shipments.service.spec.ts b/backend/src/shipments/shipments.service.spec.ts index 1c409039..39cf1248 100644 --- a/backend/src/shipments/shipments.service.spec.ts +++ b/backend/src/shipments/shipments.service.spec.ts @@ -34,6 +34,9 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: undefined as any, + recoveryCodes: [], ...overrides, }; } diff --git a/backend/src/sms/sms.module.ts b/backend/src/sms/sms.module.ts index a7d1d55e..06e441c7 100644 --- a/backend/src/sms/sms.module.ts +++ b/backend/src/sms/sms.module.ts @@ -7,4 +7,4 @@ import { SmsService } from './sms.service'; providers: [SmsService], exports: [SmsService], }) -export class SmsModule {} \ No newline at end of file +export class SmsModule {} diff --git a/backend/src/sms/sms.service.ts b/backend/src/sms/sms.service.ts index a582d731..34d3c9a4 100644 --- a/backend/src/sms/sms.service.ts +++ b/backend/src/sms/sms.service.ts @@ -23,4 +23,4 @@ export class SmsService { this.logger.warn(`Failed to send SMS to ${to}: ${msg}`); } } -} \ No newline at end of file +} diff --git a/backend/src/users/entities/two-factor-recovery.entity.ts b/backend/src/users/entities/two-factor-recovery.entity.ts index 38076f88..743b36ec 100644 --- a/backend/src/users/entities/two-factor-recovery.entity.ts +++ b/backend/src/users/entities/two-factor-recovery.entity.ts @@ -1,4 +1,11 @@ -import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + ManyToOne, + JoinColumn, + CreateDateColumn, +} from 'typeorm'; import { User } from './user.entity'; @Entity('two_factor_recoveries') @@ -21,4 +28,4 @@ export class TwoFactorRecovery { @ManyToOne(() => User, (user) => user.recoveryCodes, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'userId' }) user: User; -} \ No newline at end of file +} diff --git a/backend/src/users/users.service.spec.ts b/backend/src/users/users.service.spec.ts index 6fecd6c6..f1d31fe3 100644 --- a/backend/src/users/users.service.spec.ts +++ b/backend/src/users/users.service.spec.ts @@ -27,6 +27,9 @@ function makeUser(overrides: Partial = {}): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: undefined as any, + recoveryCodes: [], ...overrides, }; } diff --git a/backend/src/webhooks/webhooks.service.spec.ts b/backend/src/webhooks/webhooks.service.spec.ts index 7b98a106..4414afed 100644 --- a/backend/src/webhooks/webhooks.service.spec.ts +++ b/backend/src/webhooks/webhooks.service.spec.ts @@ -27,6 +27,9 @@ function makeUser(): User { resetPasswordExpiry: null, createdAt: new Date(), updatedAt: new Date(), + isTwoFactorEnabled: false, + twoFactorSecret: undefined as any, + recoveryCodes: [], }; } diff --git a/backend/test/app.e2e-spec.ts b/backend/test/app.e2e-spec.ts new file mode 100644 index 00000000..de650344 --- /dev/null +++ b/backend/test/app.e2e-spec.ts @@ -0,0 +1,26 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; + +describe('AppController (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + it('/ (GET)', () => { + const httpServer = app.getHttpServer(); + return request(httpServer).get('/').expect(200).expect('Hello World!'); + }); +}); diff --git a/backend/test/jest-e2e.json b/backend/test/jest-e2e.json new file mode 100644 index 00000000..f1aee95e --- /dev/null +++ b/backend/test/jest-e2e.json @@ -0,0 +1,12 @@ +{ + "moduleFileExtensions": ["js", "json", "ts"], + "rootDir": ".", + "testEnvironment": "node", + "testRegex": ".e2e-spec.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "jest": { + "testTimeout": 30000 + } +}