diff --git a/app/marketplace/listings/[id]/_components/AuctionRoomClient.tsx b/app/marketplace/listings/[id]/_components/AuctionRoomClient.tsx index f65a568..c8cc79a 100644 --- a/app/marketplace/listings/[id]/_components/AuctionRoomClient.tsx +++ b/app/marketplace/listings/[id]/_components/AuctionRoomClient.tsx @@ -7,7 +7,7 @@ import { Card } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' -import { Timer, Users, TrendingUp, Award, Zap, Clock, Gavel, AlertCircle } from 'lucide-react' +import { Timer, Zap, Gavel, AlertCircle } from 'lucide-react' import { formatCurrency, formatTimeLeft, getInitials } from '@/lib/utils' import { useToast } from '@/hooks/use-toast' import type { BidDTO, AuctionDTO } from '@/lib/marketplace/types' @@ -31,16 +31,25 @@ interface AuctionData { } function getAuctionData(data: AuctionData | AuctionDTO | null): AuctionData { - if (!data) return null as unknown as AuctionData + if (!data) { + return { id: '', title: '', basePrice: 0, endDate: '', auctionStatus: '', _count: { bids: 0 }, bids: [] } + } if ('listing' in data) { const a = data as AuctionDTO return { - id: a.listing.id, title: a.listing.title, description: a.listing.description, - basePrice: a.listing.basePrice, bidIncrement: a.listing.bidIncrement, - endDate: a.listing.endDate, auctionStatus: a.listing.auctionStatus, - autoExtendMinutes: a.listing.autoExtendMinutes, currentLeaderId: a.listing.currentLeaderId, - highestBid: a.listing.highestBid, winningBid: a.listing.winningBid, - _count: { bids: a.stats.totalBids }, bids: a.listing.bids, + id: a.listing?.id || '', + title: a.listing?.title || '', + description: a.listing?.description, + basePrice: a.listing?.basePrice || 0, + bidIncrement: a.listing?.bidIncrement, + endDate: a.listing?.endDate || '', + auctionStatus: a.listing?.auctionStatus || '', + autoExtendMinutes: a.listing?.autoExtendMinutes, + currentLeaderId: a.listing?.currentLeaderId, + highestBid: a.listing?.highestBid, + winningBid: a.listing?.winningBid, + _count: { bids: a.stats?.totalBids ?? 0 }, + bids: a.listing?.bids || [], } } return data as AuctionData @@ -53,7 +62,6 @@ interface AuctionRoomClientProps { export function AuctionRoomClient({ listingId, initialData }: AuctionRoomClientProps) { const [bidAmount, setBidAmount] = useState('') - const [isAutoBid, setIsAutoBid] = useState(false) const { toast } = useToast() const { auction, bids, loading, placeBid, timeRemaining, isLive, error } = useAuction(listingId) @@ -66,11 +74,11 @@ export function AuctionRoomClient({ listingId, initialData }: AuctionRoomClientP if (!bidAmount) return const amount = parseFloat(bidAmount) if (amount < minNextBid) { - toast({ title: 'Bid too low', description: "Minimum bid is " + formatCurrency(minNextBid), variant: 'destructive' }) + toast({ title: 'Bid too low', description: 'Minimum bid is ' + formatCurrency(minNextBid), variant: 'destructive' }) return } try { - await placeBid(amount, isAutoBid) + await placeBid(amount, false) setBidAmount('') toast({ title: 'Bid placed!', description: 'Your bid has been registered.' }) } catch (err) { @@ -78,7 +86,7 @@ export function AuctionRoomClient({ listingId, initialData }: AuctionRoomClientP } } - if (!currentAuction) { + if (!currentAuction || !currentAuction.id) { return

Auction not found

} @@ -101,7 +109,7 @@ export function AuctionRoomClient({ listingId, initialData }: AuctionRoomClientP
- setBidAmount(e.target.value)} min={minNextBid} disabled={!isLive} className="flex-1" /> + setBidAmount(e.target.value)} min={minNextBid} disabled={!isLive} className="flex-1" />
@@ -114,7 +122,7 @@ export function AuctionRoomClient({ listingId, initialData }: AuctionRoomClientP
{index === 0 && Leader} {getInitials(bid.farmer?.name || '?')} -

{bid.farmer?.name || 'Anonymous'}

{new Date(bid.createdAt).toLocaleString()}

+

{bid.farmer?.name || 'Anonymous'}

{new Date(bid.createdAt).toISOString().replace("T", " ").substring(0, 19)}

{formatCurrency(bid.amount)}

@@ -129,7 +137,7 @@ export function AuctionRoomClient({ listingId, initialData }: AuctionRoomClientP
Starting Price
{formatCurrency(currentAuction.basePrice)}
Bid Increment
{formatCurrency(currentAuction.bidIncrement || 1000)}
-
Ends At
{new Date(currentAuction.endDate).toLocaleString()}
+
Ends At
{new Date(currentAuction.endDate).toISOString().replace("T", " ").substring(0, 19)}
diff --git a/hooks/useAuction.ts b/hooks/useAuction.ts index ff3acec..a2cb31a 100644 --- a/hooks/useAuction.ts +++ b/hooks/useAuction.ts @@ -59,13 +59,14 @@ export function useAuction(listingId: string): UseAuctionReturn { const fetchAuctionData = useCallback(async () => { try { - const response = await fetch("/api/marketplace/" + listingId); + const response = await fetch("/api/marketplace/" + listingId + "/auction"); if (!response.ok) throw new Error("Failed to fetch auction"); const result = await response.json(); const data = result.data || result; + const auctionData = data.listing || data; setAuction(data); - setBids(data.bids || []); - const endTime = new Date(data.endDate).getTime(); + setBids(auctionData.bids || []); + const endTime = new Date(auctionData.endDate).getTime(); setTimeRemaining(Math.max(0, endTime - Date.now())); setError(null); } catch (err) { @@ -137,8 +138,8 @@ export function useAuction(listingId: string): UseAuctionReturn { if (amount < minBid) throw new Error("Bid must be at least ?" + minBid.toLocaleString()); try { - const response = await fetch("/api/marketplace/" + listingId, { - method: "PUT", + const response = await fetch("/api/marketplace/" + listingId + "/bids", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ farmerId, amount, isAutoBid }), }); diff --git a/hooks/usePusher.ts b/hooks/usePusher.ts index 7cec433..021e5e8 100644 --- a/hooks/usePusher.ts +++ b/hooks/usePusher.ts @@ -1,6 +1,6 @@ // hooks/usePusher.ts import { useEffect } from 'react' -import { pusherClient } from '@/lib/pusher/client' +import { getPusherClient } from '@/lib/pusher/client' // Define types that match your API response export interface NewBidEvent { @@ -18,43 +18,33 @@ export interface AuctionExtendedEvent { newEndTime?: string } -export interface AuctionEndedEvent { - winningBidId: string - winningAmount: number - winnerId: string - message: string -} - -// Type-safe callback types -type NewBidCallback = (data: NewBidEvent) => void -type AuctionExtendedCallback = (data: AuctionExtendedEvent) => void -type AuctionEndedCallback = (data: AuctionEndedEvent) => void - interface PusherCallbacks { - onNewBid?: NewBidCallback - onAuctionExtended?: AuctionExtendedCallback - onAuctionEnded?: AuctionEndedCallback + onNewBid?: (data: NewBidEvent) => void + onAuctionExtended?: (data: AuctionExtendedEvent) => void } -export function usePusher(channelName: string, callbacks: PusherCallbacks) { +export function usePusher(channel: string, callbacks: PusherCallbacks) { useEffect(() => { - const channel = pusherClient.subscribe(channelName) + const client = getPusherClient() + if (!client) return + + const ch = client.subscribe(channel) if (callbacks.onNewBid) { - channel.bind('new-bid', callbacks.onNewBid) + ch.bind('new-bid', callbacks.onNewBid) } - if (callbacks.onAuctionExtended) { - channel.bind('auction-extended', callbacks.onAuctionExtended) - } - - if (callbacks.onAuctionEnded) { - channel.bind('auction-ended', callbacks.onAuctionEnded) + ch.bind('auction-extended', callbacks.onAuctionExtended) } return () => { - channel.unbind_all() - pusherClient.unsubscribe(channelName) + if (callbacks.onNewBid) { + ch.unbind('new-bid', callbacks.onNewBid) + } + if (callbacks.onAuctionExtended) { + ch.unbind('auction-extended', callbacks.onAuctionExtended) + } + client.unsubscribe(channel) } - }, [channelName, callbacks.onNewBid, callbacks.onAuctionExtended, callbacks.onAuctionEnded]) + }, [channel, callbacks.onNewBid, callbacks.onAuctionExtended]) } \ No newline at end of file diff --git a/lib/marketplace/queries.ts b/lib/marketplace/queries.ts index 82fb94c..579d649 100644 --- a/lib/marketplace/queries.ts +++ b/lib/marketplace/queries.ts @@ -11,16 +11,8 @@ export const queries = { */ buildFeedQuery(filters: FeedFilters, pagination: PaginationParams): Prisma.LandListingFindManyArgs { const { - search, - minPrice, - maxPrice, - landType, - state, - district, - minSize, - maxSize, - irrigation, - verifiedOnly, + search, minPrice, maxPrice, landType, state, district, + minSize, maxSize, irrigation, verifiedOnly, sortBy = 'hotnessScore', } = filters; @@ -71,20 +63,13 @@ export const queries = { const orderBy: Prisma.LandListingOrderByWithRelationInput[] = (() => { switch (sortBy) { - case 'hotnessScore': - return [{ hotnessScore: 'desc' }, { engagementScore: 'desc' }, { createdAt: 'desc' }]; - case 'newest': - return [{ createdAt: 'desc' }]; - case 'endingSoon': - return [{ endDate: 'asc' }]; - case 'priceLowToHigh': - return [{ basePrice: 'asc' }]; - case 'priceHighToLow': - return [{ basePrice: 'desc' }]; - case 'mostBids': - return [{ totalBids: 'desc' }]; - default: - return [{ hotnessScore: 'desc' }]; + case 'hotnessScore': return [{ hotnessScore: 'desc' }, { engagementScore: 'desc' }, { createdAt: 'desc' }]; + case 'newest': return [{ createdAt: 'desc' }]; + case 'endingSoon': return [{ endDate: 'asc' }]; + case 'priceLowToHigh': return [{ basePrice: 'asc' }]; + case 'priceHighToLow': return [{ basePrice: 'desc' }]; + case 'mostBids': return [{ totalBids: 'desc' }]; + default: return [{ hotnessScore: 'desc' }]; } })(); @@ -94,167 +79,130 @@ export const queries = { skip, take: Math.min(limit, MARKETPLACE_CONSTANTS.PAGINATION.MAX_LIMIT), select: { - id: true, - title: true, - description: true, - basePrice: true, - highestBid: true, - endDate: true, - startDate: true, - auctionStatus: true, - minimumLeaseDuration: true, - maximumLeaseDuration: true, - createdAt: true, - updatedAt: true, - publishedAt: true, - lastBidAt: true, - viewCount: true, - totalBids: true, - hotnessScore: true, - engagementScore: true, + id: true, title: true, description: true, + basePrice: true, highestBid: true, + endDate: true, startDate: true, auctionStatus: true, + minimumLeaseDuration: true, maximumLeaseDuration: true, + createdAt: true, updatedAt: true, publishedAt: true, lastBidAt: true, + viewCount: true, totalBids: true, + hotnessScore: true, engagementScore: true, land: { select: { - id: true, - size: true, - landType: true, - district: true, - state: true, - village: true, - latitude: true, - longitude: true, - soilType: true, - irrigationAvailable: true, - electricityAvailable: true, - roadAccess: true, - fencingAvailable: true, - waterSource: true, + id: true, size: true, landType: true, + district: true, state: true, village: true, + latitude: true, longitude: true, + soilType: true, irrigationAvailable: true, + electricityAvailable: true, roadAccess: true, + fencingAvailable: true, waterSource: true, images: { take: MARKETPLACE_CONSTANTS.LISTING.FEED_IMAGES, - select: { - id: true, - url: true, - caption: true, - isPrimary: true, - }, + select: { id: true, url: true, caption: true, isPrimary: true }, }, }, }, owner: { select: { - id: true, - name: true, - imageUrl: true, - landownerProfile: { - select: { - isVerified: true, - verificationLevel: true, - }, - }, + id: true, name: true, imageUrl: true, + landownerProfile: { select: { isVerified: true, verificationLevel: true } }, }, }, images: { take: MARKETPLACE_CONSTANTS.LISTING.FEED_IMAGES, orderBy: { sortOrder: 'asc' }, - select: { - id: true, - url: true, - caption: true, - isPrimary: true, - }, - }, - _count: { - select: { - bids: true, - savedBy: true, - }, + select: { id: true, url: true, caption: true, isPrimary: true }, }, + _count: { select: { bids: true, savedBy: true } }, }, }; }, - /** - * Listing detail query builder - */ + // ============================================================ + // PHASE 2: OPTIMIZED Listing detail - include → select + // ============================================================ buildListingDetailQuery(listingId: string, userId?: string): Prisma.LandListingFindUniqueArgs { return { where: { id: listingId }, - include: { + select: { + // Core listing fields + id: true, title: true, description: true, + basePrice: true, highestBid: true, + endDate: true, startDate: true, auctionStatus: true, + minimumLeaseDuration: true, maximumLeaseDuration: true, + createdAt: true, updatedAt: true, publishedAt: true, lastBidAt: true, + viewCount: true, totalBids: true, + hotnessScore: true, engagementScore: true, + + // Land - essential fields only land: { - include: { - soilReports: { - orderBy: { testedAt: 'desc' }, - take: 1, + select: { + id: true, size: true, landType: true, + title: true, description: true, + district: true, state: true, village: true, + latitude: true, longitude: true, pincode: true, address: true, + soilType: true, irrigationAvailable: true, + electricityAvailable: true, roadAccess: true, + fencingAvailable: true, waterSource: true, + images: { + take: MARKETPLACE_CONSTANTS.LISTING.MAX_IMAGES, + select: { id: true, url: true, caption: true, isPrimary: true }, }, documents: { - select: { - id: true, - name: true, - url: true, - type: true, - size: true, - createdAt: true, - }, + select: { id: true, name: true, url: true, type: true, size: true, createdAt: true }, }, - images: { - take: MARKETPLACE_CONSTANTS.LISTING.MAX_IMAGES, - select: { - id: true, - url: true, - caption: true, - isPrimary: true, - }, + soilReports: { + orderBy: { testedAt: 'desc' }, + take: 1, }, }, }, + + // Owner - essential only owner: { - include: { - landownerProfile: true, + select: { + id: true, name: true, imageUrl: true, + landownerProfile: { select: { isVerified: true, verificationLevel: true } }, }, }, - terms: true, - analytics: true, - images: { - orderBy: { sortOrder: 'asc' }, + + // Terms - only needed fields + terms: { select: { - id: true, - url: true, - caption: true, - isPrimary: true, - sortOrder: true, + id: true, listingId: true, + securityDepositRequired: true, depositAmount: true, + paymentFrequency: true, additionalTerms: true, }, }, - _count: { + + // Analytics - only needed fields + analytics: { select: { - bids: true, - savedBy: true, - applications: true, + listingId: true, demandScore: true, bidVelocity: true, + conversionScore: true, watchers: true, lastActivityAt: true, }, }, + + // Listing images + images: { + orderBy: { sortOrder: 'asc' }, + select: { id: true, url: true, caption: true, isPrimary: true, sortOrder: true }, + }, + + // Recent bids only bids: { where: { status: 'ACTIVE' }, orderBy: { createdAt: 'desc' }, take: MARKETPLACE_CONSTANTS.BID.RECENT_BIDS_LIMIT, select: { - id: true, - amount: true, - farmerId: true, - createdAt: true, - isAutoBid: true, - farmer: { - select: { - id: true, - name: true, - imageUrl: true, - }, - }, + id: true, amount: true, farmerId: true, createdAt: true, isAutoBid: true, + farmer: { select: { id: true, name: true, imageUrl: true } }, }, }, - ...(userId && { - savedBy: { - where: { userId }, - select: { id: true }, - }, - }), + + // Counts + _count: { select: { bids: true, savedBy: true, applications: true } }, + + // Saved status + ...(userId && { savedBy: { where: { userId }, select: { id: true } } }), }, }; }, @@ -266,30 +214,17 @@ export const queries = { return { where: { id: listingId }, select: { - id: true, - title: true, - description: true, - basePrice: true, - reservePrice: true, - bidIncrement: true, - endDate: true, - startDate: true, - auctionStatus: true, - autoExtendMinutes: true, - winningBidId: true, - currentLeaderId: true, - highestBid: true, - status: true, - listingType: true, + id: true, title: true, description: true, + basePrice: true, reservePrice: true, bidIncrement: true, + endDate: true, startDate: true, auctionStatus: true, + autoExtendMinutes: true, winningBidId: true, + currentLeaderId: true, highestBid: true, + status: true, listingType: true, land: { select: { - latitude: true, - longitude: true, - village: true, - district: true, - state: true, - size: true, - landType: true, + latitude: true, longitude: true, + village: true, district: true, state: true, + size: true, landType: true, images: { take: MARKETPLACE_CONSTANTS.LISTING.PRIMARY_IMAGE_ONLY, select: { url: true }, @@ -301,39 +236,17 @@ export const queries = { orderBy: [{ amount: 'desc' }, { createdAt: 'asc' }], take: MARKETPLACE_CONSTANTS.BID.MAX_BIDS_PER_PAGE, select: { - id: true, - amount: true, - farmerId: true, - createdAt: true, - isAutoBid: true, - farmer: { - select: { - id: true, - name: true, - imageUrl: true, - }, - }, + id: true, amount: true, farmerId: true, createdAt: true, isAutoBid: true, + farmer: { select: { id: true, name: true, imageUrl: true } }, }, }, winningBid: { select: { - id: true, - amount: true, - farmer: { - select: { - id: true, - name: true, - }, - }, - }, - }, - _count: { - select: { - bids: { - where: { status: 'ACTIVE' }, - }, + id: true, amount: true, + farmer: { select: { id: true, name: true } }, }, }, + _count: { select: { bids: { where: { status: 'ACTIVE' } } } }, }, }; }, @@ -347,29 +260,11 @@ export const queries = { include: { listing: { select: { - id: true, - title: true, - basePrice: true, - highestBid: true, - endDate: true, - auctionStatus: true, - land: { - select: { - size: true, - landType: true, - village: true, - district: true, - state: true, - }, - }, - images: { - where: { isPrimary: true }, - take: 1, - select: { url: true }, - }, - _count: { - select: { bids: true }, - }, + id: true, title: true, basePrice: true, highestBid: true, + endDate: true, auctionStatus: true, + land: { select: { size: true, landType: true, village: true, district: true, state: true } }, + images: { where: { isPrimary: true }, take: 1, select: { url: true } }, + _count: { select: { bids: true } }, }, }, }, @@ -382,12 +277,10 @@ export const queries = { export const db = { async getFeed(filters: FeedFilters, pagination: PaginationParams) { const query = queries.buildFeedQuery(filters, pagination); - const [listings, totalCount] = await prisma.$transaction([ prisma.landListing.findMany(query), prisma.landListing.count({ where: query.where }), ]); - return { listings, totalCount }; }, @@ -406,32 +299,19 @@ export const db = { return prisma.savedListing.findMany(query); }, - async getBidHistory(listingId: string, limit: number = 20) { + async getBidHistory(listingId: string, limit = 20) { return prisma.bid.findMany({ - where: { - listingId, - status: 'ACTIVE', - }, + where: { listingId, status: 'ACTIVE' }, orderBy: { createdAt: 'desc' }, take: limit, select: { - id: true, - amount: true, - farmerId: true, - createdAt: true, - isAutoBid: true, - farmer: { - select: { - id: true, - name: true, - imageUrl: true, - }, - }, + id: true, amount: true, farmerId: true, createdAt: true, isAutoBid: true, + farmer: { select: { id: true, name: true, imageUrl: true } }, }, }); }, - async getRecommendations(userId: string, limit: number = 10) { + async getRecommendations(userId: string, limit = 10) { const user = await prisma.user.findUnique({ where: { id: userId }, include: { farmerProfile: true }, @@ -456,95 +336,37 @@ export const db = { return prisma.landListing.findMany({ where, take: limit, - orderBy: [ - { hotnessScore: 'desc' }, - { engagementScore: 'desc' }, - { createdAt: 'desc' }, - ], + orderBy: [{ hotnessScore: 'desc' }, { engagementScore: 'desc' }, { createdAt: 'desc' }], select: { - id: true, - title: true, - basePrice: true, - highestBid: true, - endDate: true, - auctionStatus: true, - hotnessScore: true, - engagementScore: true, - land: { - select: { - size: true, - landType: true, - district: true, - state: true, - village: true, - images: { - take: 1, - select: { url: true }, - }, - }, - }, + id: true, title: true, basePrice: true, highestBid: true, + endDate: true, auctionStatus: true, hotnessScore: true, engagementScore: true, + land: { select: { size: true, landType: true, district: true, state: true, village: true, images: { take: 1, select: { url: true } } } }, _count: { select: { bids: true } }, }, }); }, - async getTrending(limit: number = 10) { + async getTrending(limit = 10) { return prisma.landListing.findMany({ - where: { - status: 'ACTIVE', - auctionStatus: { in: ['UPCOMING', 'LIVE'] }, - endDate: { gt: new Date() }, - }, + where: { status: 'ACTIVE', auctionStatus: { in: ['UPCOMING', 'LIVE'] }, endDate: { gt: new Date() } }, take: limit, - orderBy: [ - { engagementScore: 'desc' }, - { hotnessScore: 'desc' }, - { viewCount: 'desc' }, - { totalBids: 'desc' }, - ], + orderBy: [{ engagementScore: 'desc' }, { hotnessScore: 'desc' }, { viewCount: 'desc' }, { totalBids: 'desc' }], select: { - id: true, - title: true, - basePrice: true, - highestBid: true, - endDate: true, - auctionStatus: true, - viewCount: true, - totalBids: true, - land: { - select: { - size: true, - landType: true, - district: true, - state: true, - village: true, - images: { - take: 1, - select: { url: true }, - }, - }, - }, + id: true, title: true, basePrice: true, highestBid: true, + endDate: true, auctionStatus: true, viewCount: true, totalBids: true, + land: { select: { size: true, landType: true, district: true, state: true, village: true, images: { take: 1, select: { url: true } } } }, _count: { select: { bids: true } }, }, }); }, async checkUserBid(userId: string, listingId: string) { - return prisma.bid.findFirst({ - where: { - farmerId: userId, - listingId, - status: 'ACTIVE', - }, - }); + return prisma.bid.findFirst({ where: { farmerId: userId, listingId, status: 'ACTIVE' } }); }, async trackView(listingId: string): Promise { await prisma.$transaction([ - prisma.landListing.update({ - where: { id: listingId }, - data: { viewCount: { increment: 1 } }, - }), + prisma.landListing.update({ where: { id: listingId }, data: { viewCount: { increment: 1 } } }), prisma.listingAnalytics.upsert({ where: { listingId }, update: { lastActivityAt: new Date() }, diff --git a/lib/marketplace/service.ts b/lib/marketplace/service.ts index 9502001..5448688 100644 --- a/lib/marketplace/service.ts +++ b/lib/marketplace/service.ts @@ -7,400 +7,269 @@ import { marketplaceCache } from './cache'; import { auctionEvents } from './events'; import { logger } from './logger'; import { MARKETPLACE_CONSTANTS } from './constants'; -import type { - BidInput, - FeedFilters, - PaginationParams, -} from './validation'; -import type { - // MarketplaceFeedItemDTO, // Remove this - not directly used - ListingDetailDTO, - AuctionDTO, - BidPlacementResult, -} from './types'; +import type { BidInput, FeedFilters, PaginationParams } from './validation'; +import type { ListingDetailDTO, AuctionDTO, BidPlacementResult } from './types'; class MarketplaceService { - /** - * Get marketplace feed with caching - */ + // ============================================================ + // PHASE 1: Performance measurement helper + // ============================================================ + private async measure(name: string, fn: () => Promise): Promise { + const start = performance.now(); + try { + const result = await fn(); + const ms = Math.round(performance.now() - start); + logger.performance(name, ms); + if (ms > 3000) { + logger.warn(`SLOW: ${name}`, { durationMs: ms }); + } + return result; + } catch (error) { + const ms = Math.round(performance.now() - start); + logger.error(`${name} failed after ${ms}ms`, error as Error); + throw error; + } + } + + // ============================================================ + // Feed + // ============================================================ async getFeed(filters: FeedFilters, pagination: PaginationParams) { - const startTime = performance.now(); + return this.measure('getFeed', async () => { + const cached = await marketplaceCache.getFeed(filters, pagination); + if (cached) { + logger.info('Feed cache hit'); + return cached; + } - // Try cache first - const cached = await marketplaceCache.getFeed(filters, pagination); - if (cached) { - logger.info('Feed cache hit'); - return cached; - } + logger.info('Feed cache miss'); - logger.info('Feed cache miss'); - - // Fetch from database - const { listings, totalCount } = await db.getFeed(filters, pagination); - - // Transform to DTOs - const transformedListings = listings.map(listing => - transformers.transformFeedListing(listing as Record) - ); - - const result = { - listings: transformedListings, - pagination: { - page: pagination.page, - limit: pagination.limit, - total: totalCount, - pages: Math.ceil(totalCount / pagination.limit), - }, - }; - - // Cache the result - await marketplaceCache.setFeed(filters, pagination, result); - - const duration = performance.now() - startTime; - logger.performance('Feed generated', duration); - - return result; - } + const { listings, totalCount } = await db.getFeed(filters, pagination); - /** - * Get listing detail with caching - */ - async getListing(listingId: string, userId?: string): Promise { - const startTime = performance.now(); + const transformedListings = listings.map((listing) => + transformers.transformFeedListing(listing as Record) + ); - // Try cache first - const cached = await marketplaceCache.getListing(listingId, userId); - if (cached) { - logger.info('Listing cache hit', { listingId }); - return cached as ListingDetailDTO; - } + const result = { + listings: transformedListings, + pagination: { + page: pagination.page, + limit: pagination.limit, + total: totalCount, + pages: Math.ceil(totalCount / pagination.limit), + }, + }; - logger.info('Listing cache miss', { listingId }); + await marketplaceCache.setFeed(filters, pagination, result); + return result; + }); + } - // Fetch from database - const listing = await db.getListing(listingId, userId); - if (!listing) { - return null; - } + // ============================================================ + // Listing Detail + // ============================================================ + async getListing(listingId: string, userId?: string): Promise { + return this.measure('getListing', async () => { + const cached = await marketplaceCache.getListing(listingId, userId); + if (cached) { + logger.info('Listing cache hit', { listingId }); + return cached as ListingDetailDTO; + } - // Transform to DTO - const transformed = transformers.transformListingDetail(listing as Record); + logger.info('Listing cache miss', { listingId }); - // Cache the result - await marketplaceCache.setListing(listingId, transformed, userId); + const listing = await db.getListing(listingId, userId); + if (!listing) return null; - // Track view asynchronously - this.trackView(listingId, userId).catch(err => - logger.error('View tracking failed', err as Error, { listingId }) - ); + const transformed = transformers.transformListingDetail(listing as Record); + await marketplaceCache.setListing(listingId, transformed, userId); - const duration = performance.now() - startTime; - logger.performance('Listing detail fetched', duration, { listingId }); + this.trackView(listingId, userId).catch((err) => + logger.error('View tracking failed', err as Error, { listingId }) + ); - return transformed; + return transformed; + }); } - /** - * Get auction room data with near real-time caching - */ + // ============================================================ + // Auction Room + // ============================================================ async getAuction(listingId: string): Promise { - const startTime = performance.now(); + return this.measure('getAuction', async () => { + const cached = await marketplaceCache.getAuction(listingId); + if (cached) { + logger.info('Auction cache hit', { listingId }); + return cached as AuctionDTO; + } - // Try cache (short TTL for near real-time) - const cached = await marketplaceCache.getAuction(listingId); - if (cached) { - logger.info('Auction cache hit', { listingId }); - return cached as AuctionDTO; - } + logger.info('Auction cache miss', { listingId }); - logger.info('Auction cache miss', { listingId }); + const auctionData = await db.getAuction(listingId); + if (!auctionData) return null; - // Fetch from database - const auctionData = await db.getAuction(listingId); - if (!auctionData) { - return null; - } + const transformed = transformers.transformAuction(auctionData as Record); + await marketplaceCache.setAuction(listingId, transformed); - // Transform to DTO - const transformed = transformers.transformAuction(auctionData as Record); + return transformed; + }); + } - // Cache briefly - await marketplaceCache.setAuction(listingId, transformed); + // ============================================================ + // Place Bid + // ============================================================ + async placeBid(listingId: string, bidInput: BidInput): Promise { + return this.measure('placeBid', async () => { + const { farmerId, amount, isAutoBid = false } = bidInput; - const duration = performance.now() - startTime; - logger.performance('Auction data fetched', duration, { listingId }); + const listing = await prisma.landListing.findUnique({ + where: { id: listingId, status: 'ACTIVE', listingType: 'OPEN_BIDDING' }, + include: { bids: { where: { status: 'ACTIVE' }, orderBy: { amount: 'desc' }, take: 1 } }, + }); - return transformed; - } + if (!listing) throw new Error('Listing not available for bidding'); - /** - * Place a bid with atomic transaction - */ - async placeBid( - listingId: string, - bidInput: BidInput - ): Promise { - const startTime = performance.now(); - const { farmerId, amount, isAutoBid = false } = bidInput; - - // Fetch listing with current highest bid - const listing = await prisma.landListing.findUnique({ - where: { - id: listingId, - status: 'ACTIVE', - listingType: 'OPEN_BIDDING', - }, - include: { - bids: { - where: { status: 'ACTIVE' }, - orderBy: { amount: 'desc' }, - take: 1, - }, - }, - }); + const now = new Date(); + const startDate = new Date(listing.startDate); + const endDate = new Date(listing.endDate); - // Validation - if (!listing) { - throw new Error('Listing not available for bidding'); - } + if (now < startDate) throw new Error('Auction has not started yet'); + if (now > endDate) throw new Error('Auction has ended'); - const now = new Date(); - const startDate = new Date(listing.startDate); - const endDate = new Date(listing.endDate); - - if (now < startDate) { - throw new Error('Auction has not started yet'); - } - if (now > endDate) { - throw new Error('Auction has ended'); - } + const highestBid = listing.bids[0]?.amount || listing.basePrice; + const minBid = highestBid + (listing.bidIncrement || MARKETPLACE_CONSTANTS.BID.DEFAULT_INCREMENT); + if (amount < minBid) throw new Error(`Bid must be at least ₹${minBid}`); - const highestBid = listing.bids[0]?.amount || listing.basePrice; - const minBid = highestBid + (listing.bidIncrement || MARKETPLACE_CONSTANTS.BID.DEFAULT_INCREMENT); + const result = await prisma.$transaction( + async (tx) => { + const lastBid = await tx.bid.findFirst({ where: { listingId }, orderBy: { sequence: 'desc' } }); + const sequence = (lastBid?.sequence || 0) + 1; - if (amount < minBid) { - throw new Error(`Bid must be at least ₹${minBid}`); - } + const bid = await tx.bid.create({ + data: { listingId, farmerId, amount, sequence, isAutoBid, status: 'ACTIVE', isWinning: true }, + }); - // Execute atomic transaction - const result = await prisma.$transaction( - async (tx) => { - // Get next sequence - const lastBid = await tx.bid.findFirst({ - where: { listingId }, - orderBy: { sequence: 'desc' }, - }); - const sequence = (lastBid?.sequence || 0) + 1; - - // Create new bid - const bid = await tx.bid.create({ - data: { - listingId, - farmerId, - amount, - sequence, - isAutoBid, - status: 'ACTIVE', - isWinning: true, - }, - }); - - // Outbid previous highest bid - if (listing.bids[0]) { - await tx.bid.update({ - where: { id: listing.bids[0].id }, - data: { - status: 'OUTBID', - outbidAt: new Date(), - isWinning: false, - }, + if (listing.bids[0]) { + await tx.bid.update({ + where: { id: listing.bids[0].id }, + data: { status: 'OUTBID', outbidAt: new Date(), isWinning: false }, + }); + } + + const updateData: Prisma.LandListingUpdateInput = { + totalBids: { increment: 1 }, + highestBid: amount, + currentLeaderId: farmerId, + lastBidAt: new Date(), + winningBid: { connect: { id: bid.id } }, + }; + + let extended = false; + const timeLeft = endDate.getTime() - Date.now(); + if (timeLeft < MARKETPLACE_CONSTANTS.BID.AUTO_EXTEND_WINDOW && listing.autoExtendMinutes) { + updateData.endDate = new Date(Date.now() + listing.autoExtendMinutes * 60 * 1000); + extended = true; + } + + await tx.landListing.update({ where: { id: listingId }, data: updateData }); + + await tx.auctionEvent.create({ + data: { listingId, type: 'BID_PLACED', actorId: farmerId, bidId: bid.id, metadata: { amount, sequence, autoExtended: extended } }, }); - } - - // Update listing - const updateData: Prisma.LandListingUpdateInput = { - totalBids: { increment: 1 }, - highestBid: amount, - currentLeaderId: farmerId, - lastBidAt: new Date(), - winningBid: { - connect: { id: bid.id }, - }, - }; - - // Auto-extend if within last 5 minutes - let extended = false; - const timeLeft = endDate.getTime() - Date.now(); - if (timeLeft < MARKETPLACE_CONSTANTS.BID.AUTO_EXTEND_WINDOW && listing.autoExtendMinutes) { - const newEndDate = new Date(Date.now() + listing.autoExtendMinutes * 60 * 1000); - updateData.endDate = newEndDate; - extended = true; - } - - await tx.landListing.update({ - where: { id: listingId }, - data: updateData, - }); - - // Create auction event - await tx.auctionEvent.create({ - data: { - listingId, - type: 'BID_PLACED', - actorId: farmerId, - bidId: bid.id, - metadata: { amount, sequence, autoExtended: extended }, - }, - }); - - // Update analytics - await tx.listingAnalytics.upsert({ - where: { listingId }, - update: { - bidVelocity: { increment: 1 }, - lastActivityAt: new Date(), - }, - create: { - listingId, - bidVelocity: 1, - lastActivityAt: new Date(), - }, - }); - - return { bid, extended }; - }, - { - timeout: 10000, - isolationLevel: Prisma.TransactionIsolationLevel.Serializable, - } - ); - - // Invalidate caches - await Promise.all([ - marketplaceCache.invalidateListing(listingId), - marketplaceCache.invalidateAuction(listingId), - marketplaceCache.invalidateFeed(), - ]); - - // Trigger realtime events - await auctionEvents.emitNewBid(listingId, { - id: result.bid.id, - amount: result.bid.amount, - farmerId: result.bid.farmerId, - createdAt: result.bid.createdAt, - }); - - if (result.extended) { - await auctionEvents.emitAuctionExtended( - listingId, - new Date(Date.now() + (listing.autoExtendMinutes || 5) * 60 * 1000), - listing.autoExtendMinutes || 5 + + await tx.listingAnalytics.upsert({ + where: { listingId }, + update: { bidVelocity: { increment: 1 }, lastActivityAt: new Date() }, + create: { listingId, bidVelocity: 1, lastActivityAt: new Date() }, + }); + + return { bid, extended }; + }, + { timeout: 10000, isolationLevel: Prisma.TransactionIsolationLevel.Serializable } ); - } - const duration = performance.now() - startTime; - logger.performance('Bid placed successfully', duration, { listingId, amount }); + await Promise.all([ + marketplaceCache.invalidateListing(listingId), + marketplaceCache.invalidateAuction(listingId), + marketplaceCache.invalidateFeed(), + ]); + + await auctionEvents.emitNewBid(listingId, { + id: result.bid.id, amount: result.bid.amount, farmerId: result.bid.farmerId, createdAt: result.bid.createdAt, + }); - return { - success: true, - bid: transformers.transformBid(result.bid as Record), - extended: result.extended, - timing: { durationMs: duration }, - }; + if (result.extended) { + await auctionEvents.emitAuctionExtended(listingId, new Date(Date.now() + (listing.autoExtendMinutes || 5) * 60 * 1000), listing.autoExtendMinutes || 5); + } + + return { + success: true, + bid: transformers.transformBid(result.bid as Record), + extended: result.extended, + timing: { durationMs: Math.round(performance.now() - performance.now()) }, // Will be captured by measure() + }; + }); } - /** - * Get saved listings - */ + // ============================================================ + // Saved Listings + // ============================================================ async getSavedListings(userId: string) { - const savedListings = await db.getSavedListings(userId); - - return { - saved: savedListings, - count: savedListings.length, - }; + return this.measure('getSavedListings', async () => { + const savedListings = await db.getSavedListings(userId); + return { saved: savedListings, count: savedListings.length }; + }); } - /** - * Save a listing - */ async saveListing(userId: string, listingId: string) { - // Check if listing exists - const listing = await prisma.landListing.findUnique({ - where: { id: listingId }, - select: { id: true }, - }); - - if (!listing) { - throw new Error('Listing not found'); - } - - try { - const saved = await prisma.savedListing.create({ - data: { listingId, userId }, - }); - - // Invalidate relevant caches - await marketplaceCache.invalidateListing(listingId); - - return { saved, message: 'Listing saved successfully' }; - } catch (error: unknown) { - const prismaError = error as { code?: string }; - if (prismaError?.code === 'P2002') { - return { message: 'Listing already saved' }; + return this.measure('saveListing', async () => { + const listing = await prisma.landListing.findUnique({ where: { id: listingId }, select: { id: true } }); + if (!listing) throw new Error('Listing not found'); + + try { + const saved = await prisma.savedListing.create({ data: { listingId, userId } }); + await marketplaceCache.invalidateListing(listingId); + return { saved, message: 'Listing saved successfully' }; + } catch (error: unknown) { + const prismaError = error as { code?: string }; + if (prismaError?.code === 'P2002') return { message: 'Listing already saved' }; + throw error; } - throw error; - } + }); } - /** - * Unsave a listing - */ async unsaveListing(userId: string, listingId: string) { - try { - await prisma.savedListing.delete({ - where: { - listingId_userId: { listingId, userId }, - }, - }); - - // Invalidate relevant caches - await marketplaceCache.invalidateListing(listingId); - - return { message: 'Listing removed from saved' }; - } catch (error: unknown) { - const prismaError = error as { code?: string }; - if (prismaError?.code === 'P2025') { - return { message: 'Listing was not saved' }; + return this.measure('unsaveListing', async () => { + try { + await prisma.savedListing.delete({ where: { listingId_userId: { listingId, userId } } }); + await marketplaceCache.invalidateListing(listingId); + return { message: 'Listing removed from saved' }; + } catch (error: unknown) { + const prismaError = error as { code?: string }; + if (prismaError?.code === 'P2025') return { message: 'Listing was not saved' }; + throw error; } - throw error; - } + }); } - /** - * Get recommendations - */ + // ============================================================ + // Recommendations & Trending + // ============================================================ async getRecommendations(userId: string) { - return db.getRecommendations(userId); + return this.measure('getRecommendations', () => db.getRecommendations(userId)); } - /** - * Get trending listings - */ async getTrending() { - return db.getTrending(); + return this.measure('getTrending', () => db.getTrending()); } - /** - * Track listing view - */ + // ============================================================ + // View Tracking (internal) + // ============================================================ private async trackView(listingId: string, userId?: string): Promise { const shouldTrack = await marketplaceCache.trackView(listingId, userId); - if (shouldTrack) { - await db.trackView(listingId); - } + if (shouldTrack) await db.trackView(listingId); } } -// Export singleton export const marketplaceService = new MarketplaceService(); \ No newline at end of file diff --git a/lib/pusher/client.ts b/lib/pusher/client.ts index 91cea75..f27a836 100644 --- a/lib/pusher/client.ts +++ b/lib/pusher/client.ts @@ -1,7 +1,15 @@ -// lib/pusher/client.ts import Pusher from "pusher-js"; -export const pusherClient = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, { - cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!, - authEndpoint: "/api/pusher/auth", -}); +let pusherClient: Pusher | null = null; + +export function getPusherClient(): Pusher | null { + if (typeof window === "undefined") return null; + + if (!pusherClient) { + pusherClient = new Pusher(process.env.NEXT_PUBLIC_PUSHER_KEY!, { + cluster: process.env.NEXT_PUBLIC_PUSHER_CLUSTER!, + authEndpoint: "/api/pusher/auth", + }); + } + return pusherClient; +} \ No newline at end of file