Agromind-backend / backend /controllers /valuechainController.js
gh-action-hf-auto
auto: sync backend from github@32fb9685
8a6248c
/**
* 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,
};