Spaces:
Running
Running
| /** | |
| * Value Chain Controller | |
| * Handles all marketplace operations for oilseed by-products | |
| */ | |
| import Listing from "../models/listing.model.js"; | |
| import Offer from "../models/offer.model.js"; | |
| import Processor from "../models/processor.model.js"; | |
| import TransformRequest from "../models/transformRequest.model.js"; | |
| import PriceHistory from "../models/priceHistory.model.js"; | |
| /** | |
| * Create a new listing | |
| * POST /valuechain/listings | |
| */ | |
| export const createListing = async (req, res) => { | |
| try { | |
| const { | |
| productType, | |
| productName, | |
| description, | |
| quantityKg, | |
| grade, | |
| harvestDate, | |
| expiryDate, | |
| location, | |
| reservePrice, | |
| priceUnit, | |
| photos, | |
| certifications, | |
| tags, | |
| metadata, | |
| } = req.body; | |
| // Validate required fields | |
| if (!productType || !quantityKg || !harvestDate || !location || !reservePrice) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: "Missing required fields: productType, quantityKg, harvestDate, location, reservePrice", | |
| }); | |
| } | |
| // Validate location format | |
| if (!location.coordinates || location.coordinates.length !== 2) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: "Location must include valid coordinates [longitude, latitude]", | |
| }); | |
| } | |
| const listing = new Listing({ | |
| sellerId: req.user._id, | |
| productType, | |
| productName: productName || productType.replace(/_/g, " ").toUpperCase(), | |
| description, | |
| quantityKg, | |
| availableQuantityKg: quantityKg, | |
| grade: grade || "standard", | |
| harvestDate: new Date(harvestDate), | |
| expiryDate: expiryDate ? new Date(expiryDate) : null, | |
| location: { | |
| type: "Point", | |
| coordinates: location.coordinates, | |
| address: location.address, | |
| district: location.district, | |
| state: location.state, | |
| pincode: location.pincode, | |
| }, | |
| reservePrice, | |
| currentPrice: reservePrice, | |
| priceUnit: priceUnit || "per_kg", | |
| photos: photos || [], | |
| certifications: certifications || [], | |
| tags: tags || [], | |
| metadata: metadata || {}, | |
| status: "active", | |
| }); | |
| await listing.save(); | |
| // Emit socket event for real-time updates | |
| const io = req.app.get("socketio"); | |
| if (io && typeof io.to === 'function') { | |
| io.to("valuechain").emit("new_listing", { | |
| listingId: listing._id, | |
| productType: listing.productType, | |
| location: listing.location, | |
| price: listing.reservePrice, | |
| }); | |
| } | |
| res.status(201).json({ | |
| success: true, | |
| data: listing, | |
| message: "Listing created successfully", | |
| }); | |
| } catch (error) { | |
| console.error("Error creating listing:", error); | |
| res.status(500).json({ | |
| success: false, | |
| error: "Failed to create listing", | |
| details: error.message, | |
| }); | |
| } | |
| }; | |
| /** | |
| * Get listings with search and filtering | |
| * GET /valuechain/listings | |
| */ | |
| export const getListings = async (req, res) => { | |
| try { | |
| const { | |
| productType, | |
| lat, | |
| lng, | |
| radius = 50, // km | |
| minPrice, | |
| maxPrice, | |
| grade, | |
| status = "active", | |
| sort = "-createdAt", | |
| page = 1, | |
| limit = 20, | |
| } = req.query; | |
| const query = { status }; | |
| // Product type filter | |
| if (productType) { | |
| query.productType = productType; | |
| } | |
| // Geospatial filter | |
| if (lat && lng) { | |
| const radiusInMeters = parseFloat(radius) * 1000; | |
| query.location = { | |
| $near: { | |
| $geometry: { | |
| type: "Point", | |
| coordinates: [parseFloat(lng), parseFloat(lat)], | |
| }, | |
| $maxDistance: radiusInMeters, | |
| }, | |
| }; | |
| } | |
| // Price range filter | |
| if (minPrice || maxPrice) { | |
| query.reservePrice = {}; | |
| if (minPrice) query.reservePrice.$gte = parseFloat(minPrice); | |
| if (maxPrice) query.reservePrice.$lte = parseFloat(maxPrice); | |
| } | |
| // Grade filter | |
| if (grade) { | |
| query.grade = grade; | |
| } | |
| const skip = (parseInt(page) - 1) * parseInt(limit); | |
| const [listings, total] = await Promise.all([ | |
| Listing.find(query) | |
| .populate("sellerId", "name email phone") | |
| .sort(sort) | |
| .skip(skip) | |
| .limit(parseInt(limit)) | |
| .lean(), | |
| Listing.countDocuments(query), | |
| ]); | |
| res.json({ | |
| success: true, | |
| data: listings, | |
| pagination: { | |
| total, | |
| page: parseInt(page), | |
| limit: parseInt(limit), | |
| pages: Math.ceil(total / parseInt(limit)), | |
| }, | |
| }); | |
| } catch (error) { | |
| console.error("Error fetching listings:", error); | |
| res.status(500).json({ | |
| success: false, | |
| error: "Failed to fetch listings", | |
| details: error.message, | |
| }); | |
| } | |
| }; | |
| /** | |
| * Get a single listing by ID | |
| * GET /valuechain/listings/:id | |
| */ | |
| export const getListingById = async (req, res) => { | |
| try { | |
| const { id } = req.params; | |
| const listing = await Listing.findById(id) | |
| .populate("sellerId", "name email phone") | |
| .lean(); | |
| if (!listing) { | |
| return res.status(404).json({ | |
| success: false, | |
| error: "Listing not found", | |
| }); | |
| } | |
| // Increment view count | |
| await Listing.findByIdAndUpdate(id, { $inc: { viewCount: 1 } }); | |
| res.json({ | |
| success: true, | |
| data: listing, | |
| }); | |
| } catch (error) { | |
| console.error("Error fetching listing:", error); | |
| res.status(500).json({ | |
| success: false, | |
| error: "Failed to fetch listing", | |
| details: error.message, | |
| }); | |
| } | |
| }; | |
| /** | |
| * Update a listing | |
| * PUT /valuechain/listings/:id | |
| */ | |
| export const updateListing = async (req, res) => { | |
| try { | |
| const { id } = req.params; | |
| const updates = req.body; | |
| const listing = await Listing.findById(id); | |
| if (!listing) { | |
| return res.status(404).json({ | |
| success: false, | |
| error: "Listing not found", | |
| }); | |
| } | |
| // Check ownership | |
| if (listing.sellerId.toString() !== req.user._id.toString()) { | |
| return res.status(403).json({ | |
| success: false, | |
| error: "Not authorized to update this listing", | |
| }); | |
| } | |
| // Prevent updating certain fields | |
| delete updates.sellerId; | |
| delete updates.offerCount; | |
| delete updates.viewCount; | |
| const updatedListing = await Listing.findByIdAndUpdate( | |
| id, | |
| { $set: updates }, | |
| { new: true, runValidators: true } | |
| ); | |
| res.json({ | |
| success: true, | |
| data: updatedListing, | |
| message: "Listing updated successfully", | |
| }); | |
| } catch (error) { | |
| console.error("Error updating listing:", error); | |
| res.status(500).json({ | |
| success: false, | |
| error: "Failed to update listing", | |
| details: error.message, | |
| }); | |
| } | |
| }; | |
| /** | |
| * Create an offer on a listing | |
| * POST /valuechain/offer | |
| */ | |
| export const createOffer = async (req, res) => { | |
| try { | |
| const { | |
| listingId, | |
| offeredPrice, | |
| quantityKg, | |
| message, | |
| deliveryTerms, | |
| paymentTerms, | |
| expiresInHours = 48, | |
| } = req.body; | |
| // Validate required fields | |
| if (!listingId || !offeredPrice || !quantityKg) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: "Missing required fields: listingId, offeredPrice, quantityKg", | |
| }); | |
| } | |
| // Get the listing | |
| const listing = await Listing.findById(listingId); | |
| if (!listing) { | |
| return res.status(404).json({ | |
| success: false, | |
| error: "Listing not found", | |
| }); | |
| } | |
| if (listing.status !== "active") { | |
| return res.status(400).json({ | |
| success: false, | |
| error: "Listing is not available for offers", | |
| }); | |
| } | |
| if (quantityKg > listing.availableQuantityKg) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: `Requested quantity exceeds available quantity (${listing.availableQuantityKg} kg)`, | |
| }); | |
| } | |
| // Can't make offer on own listing | |
| if (listing.sellerId.toString() === req.user._id.toString()) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: "Cannot make offer on your own listing", | |
| }); | |
| } | |
| const expiresAt = new Date(Date.now() + expiresInHours * 60 * 60 * 1000); | |
| const offer = new Offer({ | |
| listingId, | |
| buyerId: req.user._id, | |
| sellerId: listing.sellerId, | |
| offeredPrice, | |
| priceUnit: listing.priceUnit, | |
| quantityKg, | |
| message, | |
| deliveryTerms, | |
| paymentTerms: paymentTerms || { method: "escrow" }, | |
| expiresAt, | |
| }); | |
| await offer.save(); | |
| // Update listing offer count | |
| await Listing.findByIdAndUpdate(listingId, { $inc: { offerCount: 1 } }); | |
| // Emit socket event | |
| const io = req.app.get("socketio"); | |
| if (io && typeof io.to === 'function') { | |
| io.to("valuechain").emit("new_offer", { | |
| offerId: offer._id, | |
| listingId, | |
| sellerId: listing.sellerId, | |
| }); | |
| } | |
| res.status(201).json({ | |
| success: true, | |
| data: offer, | |
| message: "Offer submitted successfully", | |
| }); | |
| } catch (error) { | |
| console.error("Error creating offer:", error); | |
| res.status(500).json({ | |
| success: false, | |
| error: "Failed to create offer", | |
| details: error.message, | |
| }); | |
| } | |
| }; | |
| /** | |
| * Get offers for a user (as buyer or seller) | |
| * GET /valuechain/offers | |
| */ | |
| export const getOffers = async (req, res) => { | |
| try { | |
| const { role = "buyer", status, page = 1, limit = 20 } = req.query; | |
| const query = {}; | |
| if (role === "buyer") { | |
| query.buyerId = req.user._id; | |
| } else { | |
| query.sellerId = req.user._id; | |
| } | |
| if (status) { | |
| query.status = status; | |
| } | |
| const skip = (parseInt(page) - 1) * parseInt(limit); | |
| const [offers, total] = await Promise.all([ | |
| Offer.find(query) | |
| .populate("listingId", "productType productName reservePrice photos") | |
| .populate("buyerId", "name email") | |
| .populate("sellerId", "name email") | |
| .sort("-createdAt") | |
| .skip(skip) | |
| .limit(parseInt(limit)) | |
| .lean(), | |
| Offer.countDocuments(query), | |
| ]); | |
| res.json({ | |
| success: true, | |
| data: offers, | |
| pagination: { | |
| total, | |
| page: parseInt(page), | |
| limit: parseInt(limit), | |
| pages: Math.ceil(total / parseInt(limit)), | |
| }, | |
| }); | |
| } catch (error) { | |
| console.error("Error fetching offers:", error); | |
| res.status(500).json({ | |
| success: false, | |
| error: "Failed to fetch offers", | |
| details: error.message, | |
| }); | |
| } | |
| }; | |
| /** | |
| * Respond to an offer (accept/reject/counter) | |
| * PUT /valuechain/offer/:id/respond | |
| */ | |
| export const respondToOffer = async (req, res) => { | |
| try { | |
| const { id } = req.params; | |
| const { action, counterPrice, counterMessage } = req.body; | |
| const offer = await Offer.findById(id); | |
| if (!offer) { | |
| return res.status(404).json({ | |
| success: false, | |
| error: "Offer not found", | |
| }); | |
| } | |
| // Check if user is the seller | |
| if (offer.sellerId.toString() !== req.user._id.toString()) { | |
| return res.status(403).json({ | |
| success: false, | |
| error: "Not authorized to respond to this offer", | |
| }); | |
| } | |
| if (offer.status !== "pending") { | |
| return res.status(400).json({ | |
| success: false, | |
| error: "Can only respond to pending offers", | |
| }); | |
| } | |
| switch (action) { | |
| case "accept": | |
| offer.status = "accepted"; | |
| offer.acceptedAt = new Date(); | |
| // Update listing available quantity | |
| await Listing.findByIdAndUpdate(offer.listingId, { | |
| $inc: { availableQuantityKg: -offer.quantityKg }, | |
| }); | |
| break; | |
| case "reject": | |
| offer.status = "rejected"; | |
| break; | |
| case "counter": | |
| if (!counterPrice) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: "Counter price is required for counter offers", | |
| }); | |
| } | |
| offer.status = "countered"; | |
| offer.counterOffer = { | |
| price: counterPrice, | |
| message: counterMessage, | |
| createdAt: new Date(), | |
| }; | |
| break; | |
| default: | |
| return res.status(400).json({ | |
| success: false, | |
| error: "Invalid action. Use: accept, reject, or counter", | |
| }); | |
| } | |
| await offer.save(); | |
| // Emit socket event | |
| const io = req.app.get("socketio"); | |
| if (io && typeof io.to === 'function') { | |
| io.to("valuechain").emit("offer_response", { | |
| offerId: offer._id, | |
| buyerId: offer.buyerId, | |
| action, | |
| }); | |
| } | |
| res.json({ | |
| success: true, | |
| data: offer, | |
| message: `Offer ${action}ed successfully`, | |
| }); | |
| } catch (error) { | |
| console.error("Error responding to offer:", error); | |
| res.status(500).json({ | |
| success: false, | |
| error: "Failed to respond to offer", | |
| details: error.message, | |
| }); | |
| } | |
| }; | |
| /** | |
| * Create a transform request (processor buying raw materials) | |
| * POST /valuechain/transformRequest | |
| */ | |
| export const createTransformRequest = async (req, res) => { | |
| try { | |
| const { | |
| listingId, | |
| sellerId, | |
| requestType, | |
| rawMaterial, | |
| expectedOutput, | |
| processingFee, | |
| timeline, | |
| terms, | |
| } = req.body; | |
| // Find processor profile for current user | |
| const processor = await Processor.findOne({ userId: req.user._id }); | |
| if (!processor) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: "Processor profile not found. Please create a processor profile first.", | |
| }); | |
| } | |
| if (!rawMaterial || !rawMaterial.productType || !rawMaterial.quantityKg) { | |
| return res.status(400).json({ | |
| success: false, | |
| error: "Raw material details are required", | |
| }); | |
| } | |
| const transformRequest = new TransformRequest({ | |
| processorId: processor._id, | |
| listingId, | |
| sellerId, | |
| requestType: requestType || "spot_purchase", | |
| rawMaterial, | |
| expectedOutput: expectedOutput || [], | |
| processingFee, | |
| timeline, | |
| terms, | |
| }); | |
| await transformRequest.save(); | |
| res.status(201).json({ | |
| success: true, | |
| data: transformRequest, | |
| message: "Transform request created successfully", | |
| }); | |
| } catch (error) { | |
| console.error("Error creating transform request:", error); | |
| res.status(500).json({ | |
| success: false, | |
| error: "Failed to create transform request", | |
| details: error.message, | |
| }); | |
| } | |
| }; | |
| /** | |
| * Get market summary with aggregated supply-demand data | |
| * GET /valuechain/market-summary | |
| */ | |
| export const getMarketSummary = async (req, res) => { | |
| try { | |
| const { productType, state, days = 30 } = req.query; | |
| const dateFilter = new Date(); | |
| dateFilter.setDate(dateFilter.getDate() - parseInt(days)); | |
| // Aggregate supply data | |
| const supplyPipeline = [ | |
| { | |
| $match: { | |
| status: "active", | |
| createdAt: { $gte: dateFilter }, | |
| ...(productType && { productType }), | |
| ...(state && { "location.state": state }), | |
| }, | |
| }, | |
| { | |
| $group: { | |
| _id: { | |
| productType: "$productType", | |
| state: "$location.state", | |
| }, | |
| totalQuantity: { $sum: "$availableQuantityKg" }, | |
| averagePrice: { $avg: "$reservePrice" }, | |
| listingCount: { $sum: 1 }, | |
| minPrice: { $min: "$reservePrice" }, | |
| maxPrice: { $max: "$reservePrice" }, | |
| }, | |
| }, | |
| { | |
| $sort: { totalQuantity: -1 }, | |
| }, | |
| ]; | |
| // Aggregate demand data (from offers) | |
| const demandPipeline = [ | |
| { | |
| $match: { | |
| status: { $in: ["pending", "accepted"] }, | |
| createdAt: { $gte: dateFilter }, | |
| }, | |
| }, | |
| { | |
| $lookup: { | |
| from: "listings", | |
| localField: "listingId", | |
| foreignField: "_id", | |
| as: "listing", | |
| }, | |
| }, | |
| { $unwind: "$listing" }, | |
| { | |
| $match: { | |
| ...(productType && { "listing.productType": productType }), | |
| ...(state && { "listing.location.state": state }), | |
| }, | |
| }, | |
| { | |
| $group: { | |
| _id: { | |
| productType: "$listing.productType", | |
| state: "$listing.location.state", | |
| }, | |
| totalDemand: { $sum: "$quantityKg" }, | |
| averageOfferPrice: { $avg: "$offeredPrice" }, | |
| offerCount: { $sum: 1 }, | |
| }, | |
| }, | |
| ]; | |
| // Get price trends | |
| const priceTrendPipeline = [ | |
| { | |
| $match: { | |
| date: { $gte: dateFilter }, | |
| ...(productType && { commodity: productType }), | |
| }, | |
| }, | |
| { | |
| $group: { | |
| _id: { | |
| date: { $dateToString: { format: "%Y-%m-%d", date: "$date" } }, | |
| commodity: "$commodity", | |
| }, | |
| averagePrice: { $avg: "$prices.modal" }, | |
| volume: { $sum: "$volume.arrivals" }, | |
| }, | |
| }, | |
| { | |
| $sort: { "_id.date": 1 }, | |
| }, | |
| ]; | |
| const [supplyData, demandData, priceTrends] = await Promise.all([ | |
| Listing.aggregate(supplyPipeline), | |
| Offer.aggregate(demandPipeline), | |
| PriceHistory.aggregate(priceTrendPipeline), | |
| ]); | |
| // Calculate heatmap data (state-wise supply-demand ratio) | |
| const heatmapData = {}; | |
| supplyData.forEach((item) => { | |
| const key = `${item._id.state}_${item._id.productType}`; | |
| if (!heatmapData[key]) { | |
| heatmapData[key] = { | |
| state: item._id.state, | |
| productType: item._id.productType, | |
| supply: 0, | |
| demand: 0, | |
| }; | |
| } | |
| heatmapData[key].supply = item.totalQuantity; | |
| heatmapData[key].averagePrice = item.averagePrice; | |
| }); | |
| demandData.forEach((item) => { | |
| const key = `${item._id.state}_${item._id.productType}`; | |
| if (heatmapData[key]) { | |
| heatmapData[key].demand = item.totalDemand; | |
| } | |
| }); | |
| // Calculate price indices | |
| const priceIndices = supplyData.reduce((acc, item) => { | |
| if (!acc[item._id.productType]) { | |
| acc[item._id.productType] = { | |
| average: 0, | |
| min: Infinity, | |
| max: 0, | |
| count: 0, | |
| }; | |
| } | |
| acc[item._id.productType].average += item.averagePrice; | |
| acc[item._id.productType].min = Math.min(acc[item._id.productType].min, item.minPrice); | |
| acc[item._id.productType].max = Math.max(acc[item._id.productType].max, item.maxPrice); | |
| acc[item._id.productType].count += 1; | |
| return acc; | |
| }, {}); | |
| Object.keys(priceIndices).forEach((key) => { | |
| priceIndices[key].average = priceIndices[key].average / priceIndices[key].count; | |
| }); | |
| res.json({ | |
| success: true, | |
| data: { | |
| supply: supplyData, | |
| demand: demandData, | |
| heatmap: Object.values(heatmapData), | |
| priceTrends, | |
| priceIndices, | |
| generatedAt: new Date(), | |
| periodDays: parseInt(days), | |
| }, | |
| }); | |
| } catch (error) { | |
| console.error("Error fetching market summary:", error); | |
| res.status(500).json({ | |
| success: false, | |
| error: "Failed to fetch market summary", | |
| details: error.message, | |
| }); | |
| } | |
| }; | |
| export default { | |
| createListing, | |
| getListings, | |
| getListingById, | |
| updateListing, | |
| createOffer, | |
| getOffers, | |
| respondToOffer, | |
| createTransformRequest, | |
| getMarketSummary, | |
| }; | |