api-v1/routes/listingRoutes.js

809 lines
26 KiB
JavaScript

import express from "express";
import { insert, select, update, execute } from "../db/queryHelper/index.js";
const router = express.Router();
// Helper function to validate UUID format
const isValidUUID = (uuid) => {
if (!uuid || typeof uuid !== 'string') return false;
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
return uuidRegex.test(uuid);
};
const createNewLocation = async (trx, userId, locationData) => {
const {
lat,
lng,
source_type,
source_confidence,
selected_location,
// Location Details
is_saved_address,
location_type,
country,
state,
district,
city_village,
pincode,
} = locationData;
// Validate user exists if this is a saved address
let finalUserId = userId;
if (is_saved_address && userId) {
const userCheck = await trx('users')
.select('id')
.where({ id: userId, deleted: false })
.first();
if (!userCheck) {
throw new Error(`User with id ${userId} does not exist. Cannot create saved address for non-existent user.`);
}
} else if (!is_saved_address) {
// For captured locations (not saved addresses), user_id can be NULL
finalUserId = null;
} else if (is_saved_address && !userId) {
throw new Error("user_id is required when is_saved_address is true");
}
// Insert into locations
const locationDataToInsert = {
user_id: finalUserId,
lat,
lng,
source_type: source_type || "manual",
source_confidence: source_confidence || "unknown",
selected_location: selected_location || false,
is_saved_address: is_saved_address || false,
location_type: location_type || "other",
country,
state,
district,
city_village,
pincode
};
try {
const locationResult = await trx('locations')
.insert(locationDataToInsert)
.returning('id');
if (locationResult.length === 0) {
throw new Error("Failed to insert location record");
}
return locationResult[0].id;
} catch (error) {
console.error("Error creating new location:", error);
throw error; // Propagate error to trigger transaction rollback
}
};
// 1. GET / (Main Feed) - Optimized with idx_listings_feed_optimized
router.get("/", async (req, res) => {
try {
// Parse and validate query parameters
const status = req.query.status || "active";
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const offset = parseInt(req.query.offset) || 0;
// Parse filter parameters
const species_id = req.query.species_id;
const breed_id = req.query.breed_id;
const sex = req.query.sex;
const pregnancy_status = req.query.pregnancy_status;
const age_min = req.query.age_min ? parseInt(req.query.age_min) : undefined;
const age_max = req.query.age_max ? parseInt(req.query.age_max) : undefined;
const weight_min = req.query.weight_min ? parseFloat(req.query.weight_min) : undefined;
const weight_max = req.query.weight_max ? parseFloat(req.query.weight_max) : undefined;
const calving_number_min = req.query.calving_number_min ? parseInt(req.query.calving_number_min) : undefined;
const calving_number_max = req.query.calving_number_max ? parseInt(req.query.calving_number_max) : undefined;
const milking_capacity_min = req.query.milking_capacity_min ? parseFloat(req.query.milking_capacity_min) : undefined;
const milking_capacity_max = req.query.milking_capacity_max ? parseFloat(req.query.milking_capacity_max) : undefined;
const price_min = req.query.price_min ? parseFloat(req.query.price_min) : undefined;
const price_max = req.query.price_max ? parseFloat(req.query.price_max) : undefined;
// Build where conditions using JSON-based structure
const where = { deleted: false, status };
if (species_id) where.filter_species_id = species_id;
if (breed_id) where.filter_breed_id = breed_id;
if (sex) where.filter_sex = sex;
if (pregnancy_status) where.filter_pregnancy_status = pregnancy_status;
// Use raw builder for complex range queries (queryHelper doesn't support range operators directly)
const result = await execute({
type: 'raw-builder',
handler: async (knex) => {
let query = knex('listings')
.select('*')
.where(where);
// Add range conditions using JSON-based where structure
if (age_min !== undefined) {
query = query.where('filter_age_months', '>=', age_min);
}
if (age_max !== undefined) {
query = query.where('filter_age_months', '<=', age_max);
}
if (weight_min !== undefined) {
query = query.where('filter_weight_kg', '>=', weight_min);
}
if (weight_max !== undefined) {
query = query.where('filter_weight_kg', '<=', weight_max);
}
if (calving_number_min !== undefined) {
query = query.where('filter_calving_number', '>=', calving_number_min);
}
if (calving_number_max !== undefined) {
query = query.where('filter_calving_number', '<=', calving_number_max);
}
if (milking_capacity_min !== undefined) {
query = query.where('filter_milking_capacity', '>=', milking_capacity_min);
}
if (milking_capacity_max !== undefined) {
query = query.where('filter_milking_capacity', '<=', milking_capacity_max);
}
if (price_min !== undefined) {
query = query.where('price', '>=', price_min);
}
if (price_max !== undefined) {
query = query.where('price', '<=', price_max);
}
return await query
.orderBy('created_at', 'desc')
.limit(limit)
.offset(offset);
}
});
res.json(result);
} catch (err) {
console.error("Error fetching listings feed:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// 2. GET /near-me (Spatial Search) - Optimized with idx_listings_spatial
router.get("/near-me", async (req, res) => {
try {
// Parse and validate query parameters
const lat = parseFloat(req.query.lat);
const lng = parseFloat(req.query.lng);
const radius_meters = parseInt(req.query.radius_meters) || 100000;
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const offset = parseInt(req.query.offset) || 0;
if (!lat || !lng || isNaN(lat) || isNaN(lng)) {
return res.status(400).json({ error: "Valid latitude and longitude are required" });
}
// Use raw builder for PostGIS spatial queries (queryHelper doesn't support PostGIS functions)
const result = await execute({
type: 'raw-builder',
handler: async (knex) => {
return await knex('listings')
.select('*')
.where({ deleted: false, status: 'active' })
.whereRaw(
'ST_DWithin(filter_location_geog, ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ?)',
[lng, lat, radius_meters]
)
.orderByRaw(
'filter_location_geog <-> ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography',
[lng, lat]
)
.limit(limit)
.offset(offset);
}
});
res.json(result);
} catch (err) {
console.error("Error fetching nearby listings:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// 3. GET /search TODO: (Full Text Search) - Optimized with idx_listings_search_gin
router.get("/search", async (req, res) => {
try {
// Parse and validate query parameters
const q = req.query.q?.trim();
const limit = Math.min(parseInt(req.query.limit) || 20, 100);
const offset = parseInt(req.query.offset) || 0;
if (!q) {
return res.status(400).json({ error: "Search query 'q' is required" });
}
// Use raw builder for full-text search (queryHelper doesn't support PostgreSQL full-text search)
const result = await execute({
type: 'raw-builder',
handler: async (knex) => {
return await knex('listings')
.select('*')
.where({ deleted: false })
.whereRaw("search_vector @@ plainto_tsquery('english', ?)", [q])
.limit(limit)
.offset(offset);
}
});
res.json(result);
} catch (err) {
console.error("Error searching listings:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// 4. GET /seller/:sellerId (My Listings) - Optimized with idx_listings_seller_status
router.get("/seller/:sellerId", async (req, res) => {
try {
// Parse and validate parameters
const { sellerId } = req.params;
const status = req.query.status; // Optional status filter
const limit = Math.min(parseInt(req.query.limit) || 100, 100);
const offset = parseInt(req.query.offset) || 0;
// Build where conditions using JSON-based structure
const where = { deleted: false, seller_id: sellerId };
if (status) where.status = status;
// Use queryHelper select with JSON-based where conditions
const result = await select({
table: 'listings',
where,
orderBy: { column: 'created_at', direction: 'desc' },
limit,
offset
});
res.json(result);
} catch (err) {
console.error("Error fetching seller listings:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// 5. CRUD Operations
// CREATE Listing
router.post("/", async (req, res) => {
try {
// Parse and extract listing data from request body
const {
// Listing details
seller_id,
title,
price,
currency,
is_negotiable,
listing_type,
animal,
media // Array of { media_url, media_type, is_primary, sort_order }
} = req.body;
// Validate required fields
if (!seller_id) {
return res.status(400).json({ error: "seller_id is required" });
}
if (!isValidUUID(seller_id)) {
return res.status(400).json({ error: "seller_id must be a valid UUID" });
}
if (!title || !title.trim()) {
return res.status(400).json({ error: "title is required" });
}
if (!animal) {
return res.status(400).json({ error: "animal data is required" });
}
// Validate animal required fields
if (!animal.species_id) {
return res.status(400).json({ error: "animal.species_id is required" });
}
if (!isValidUUID(animal.species_id)) {
return res.status(400).json({ error: "animal.species_id must be a valid UUID" });
}
if (animal.breed_id && !isValidUUID(animal.breed_id)) {
return res.status(400).json({ error: "animal.breed_id must be a valid UUID" });
}
if (animal.location_id && !isValidUUID(animal.location_id)) {
return res.status(400).json({ error: "animal.location_id must be a valid UUID" });
}
// Validate seller exists using queryHelper
const sellerCheck = await select({
table: 'users',
columns: ['id'],
where: { id: seller_id, deleted: false },
limit: 1
});
if (sellerCheck.length === 0) {
return res.status(400).json({ error: `Seller with id ${seller_id} does not exist. Please create the user first.` });
}
const result = await execute({
type: 'transaction',
handler: async (trx) => {
let final_location_id = animal?.location_id;
// 1. Create Location (if needed)
if (!final_location_id && animal?.new_location) {
final_location_id = await createNewLocation(trx, seller_id, animal.new_location);
}
// 2. Create Animal - Parse and build JSON data object
const animalData = {
species_id: animal.species_id, // Already validated as UUID
breed_id: animal.breed_id && isValidUUID(animal.breed_id) ? animal.breed_id : null,
location_id: final_location_id, // Can be null or valid UUID
sex: animal.sex || null,
age_months: animal.age_months ? parseInt(animal.age_months) : null,
weight_kg: animal.weight_kg ? parseFloat(animal.weight_kg) : null,
color_markings: animal.color_markings || null,
quantity: animal.quantity ? parseInt(animal.quantity) : 1,
purpose: animal.purpose || null,
health_status: animal.health_status || "healthy",
vaccinated: animal.vaccinated === true || animal.vaccinated === 'true',
dewormed: animal.dewormed === true || animal.dewormed === 'true',
pregnancy_status: animal.pregnancy_status || "unknown",
calving_number: animal.calving_number ? parseInt(animal.calving_number) : null,
milk_yield_litre_per_day: animal.milk_yield_litre_per_day ? parseFloat(animal.milk_yield_litre_per_day) : null,
ear_tag_no: animal.ear_tag_no || null,
description: animal.description || null,
};
const animalResult = await trx('animals')
.insert(animalData)
.returning('id');
const animal_id = animalResult[0].id;
// 3. Create Listing - Parse and build JSON data object
const listingData = {
seller_id,
animal_id,
title: title.trim(),
price: price ? parseFloat(price) : null,
currency: currency || null,
is_negotiable: is_negotiable === true || is_negotiable === 'true',
listing_type: listing_type || null
};
const listingResult = await trx('listings')
.insert(listingData)
.returning('*');
const listing_id = listingResult[0].id;
// 4. Create Listing Media - Parse and build JSON data array
if (media && Array.isArray(media) && media.length > 0) {
const mediaData = media.map(item => ({
listing_id,
media_url: item.media_url?.trim() || null,
media_type: item.media_type || 'image',
is_primary: item.is_primary === true || item.is_primary === 'true',
sort_order: item.sort_order ? parseInt(item.sort_order) : 0
}));
await trx('listing_media').insert(mediaData);
}
return listingResult[0];
}
});
res.status(201).json(result);
} catch (err) {
console.error("Error creating listing with animal:", err);
// Provide more specific error messages
if (err.code === '22P02') {
// Invalid UUID format
return res.status(400).json({
error: "Invalid UUID format. Please ensure all UUID fields (species_id, breed_id, location_id) are valid UUIDs."
});
}
if (err.code === '23503') {
// Foreign key violation
return res.status(400).json({
error: err.detail || "Foreign key constraint violation. Please ensure species_id, breed_id, and location_id reference existing records."
});
}
if (err.code === '23505') {
// Unique constraint violation
return res.status(400).json({
error: err.detail || "A record with these values already exists."
});
}
res.status(500).json({ error: err.message || "Internal server error" });
}
});
// GET Single Listing
router.get("/:id", async (req, res) => {
try {
const { id } = req.params;
// Use raw builder for complex join with JSON aggregation
const result = await execute({
type: 'raw-builder',
handler: async (knex) => {
return await knex('listings as l')
.select(
'l.*',
knex.raw('row_to_json(a) as animal'),
knex.raw(`COALESCE(
(
SELECT json_agg(m ORDER BY m.is_primary DESC, m.sort_order ASC)
FROM listing_media m
WHERE m.listing_id = l.id AND m.deleted = FALSE
),
'[]'
) as media`)
)
.join('animals as a', 'l.animal_id', 'a.id')
.where('l.id', id)
.where('l.deleted', false)
.first();
}
});
if (!result) {
return res.status(404).json({ error: "Listing not found" });
}
res.json(result);
} catch (err) {
console.error("Error fetching listing:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// UPDATE Listing
router.put("/:id", async (req, res) => {
try {
const { id } = req.params;
// Parse and extract update data from request body
const {
title, price, currency, is_negotiable, listing_type, status,
animal // Animal object
} = req.body;
const result = await execute({
type: 'transaction',
handler: async (trx) => {
// 1. Update Listing - Parse and build JSON update object
const listingUpdates = {};
if (title !== undefined) listingUpdates.title = title ? title.trim() : null;
if (price !== undefined) listingUpdates.price = price !== null ? parseFloat(price) : null;
if (currency !== undefined) listingUpdates.currency = currency || null;
if (is_negotiable !== undefined) listingUpdates.is_negotiable = is_negotiable === true || is_negotiable === 'true';
if (listing_type !== undefined) listingUpdates.listing_type = listing_type || null;
if (status !== undefined) listingUpdates.status = status || null;
const listingResult = await trx('listings')
.where({ id, deleted: false })
.update(listingUpdates)
.returning('*');
if (listingResult.length === 0) {
throw new Error("Listing not found");
}
const listing = listingResult[0];
// 2. Update Animal (if provided) - Parse and build JSON update object
if (animal) {
const animalUpdates = {};
if (animal.species_id !== undefined) animalUpdates.species_id = animal.species_id;
if (animal.breed_id !== undefined) animalUpdates.breed_id = animal.breed_id;
if (animal.sex !== undefined) animalUpdates.sex = animal.sex;
if (animal.age_months !== undefined) animalUpdates.age_months = animal.age_months ? parseInt(animal.age_months) : null;
if (animal.weight_kg !== undefined) animalUpdates.weight_kg = animal.weight_kg ? parseFloat(animal.weight_kg) : null;
if (animal.color_markings !== undefined) animalUpdates.color_markings = animal.color_markings || null;
if (animal.quantity !== undefined) animalUpdates.quantity = animal.quantity ? parseInt(animal.quantity) : null;
if (animal.purpose !== undefined) animalUpdates.purpose = animal.purpose || null;
if (animal.health_status !== undefined) animalUpdates.health_status = animal.health_status;
if (animal.vaccinated !== undefined) animalUpdates.vaccinated = animal.vaccinated === true || animal.vaccinated === 'true';
if (animal.dewormed !== undefined) animalUpdates.dewormed = animal.dewormed === true || animal.dewormed === 'true';
if (animal.pregnancy_status !== undefined) animalUpdates.pregnancy_status = animal.pregnancy_status;
if (animal.calving_number !== undefined) animalUpdates.calving_number = animal.calving_number ? parseInt(animal.calving_number) : null;
if (animal.milk_yield_litre_per_day !== undefined) animalUpdates.milk_yield_litre_per_day = animal.milk_yield_litre_per_day ? parseFloat(animal.milk_yield_litre_per_day) : null;
if (animal.ear_tag_no !== undefined) animalUpdates.ear_tag_no = animal.ear_tag_no || null;
if (animal.description !== undefined) animalUpdates.description = animal.description || null;
await trx('animals')
.where({ id: listing.animal_id })
.update(animalUpdates);
}
// 3. Update Media (if provided) - Parse and build JSON data array
if (req.body.media && Array.isArray(req.body.media)) {
const media = req.body.media;
// Soft delete existing media
await trx('listing_media')
.where({ listing_id: id })
.update({ deleted: true });
// Insert new media
if (media.length > 0) {
const mediaData = media.map(item => ({
listing_id: id,
media_url: item.media_url?.trim() || null,
media_type: item.media_type || 'image',
is_primary: item.is_primary === true || item.is_primary === 'true',
sort_order: item.sort_order ? parseInt(item.sort_order) : 0
}));
await trx('listing_media').insert(mediaData);
}
}
// Fetch complete updated data
const completeResult = await trx('listings as l')
.select('l.*', trx.raw('row_to_json(a) as animal'))
.join('animals as a', 'l.animal_id', 'a.id')
.where('l.id', id)
.first();
return completeResult;
}
});
res.json(result);
} catch (err) {
if (err.message === "Listing not found") {
return res.status(404).json({ error: err.message });
}
console.error("Error updating listing:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// SOFT DELETE Listing
router.delete("/:id", async (req, res) => {
try {
const { id } = req.params;
// Parse and extract delete reason from request body
const { deleted_reason } = req.body;
// Build update data object using JSON-based structure
const updateData = {
deleted: true,
status: 'deleted'
};
if (deleted_reason !== undefined) {
updateData.deleted_reason = deleted_reason.trim();
}
// Use queryHelper update with JSON-based where conditions
const result = await update({
table: 'listings',
data: updateData,
where: {
id,
deleted: false // Only delete if not already deleted
},
returning: '*'
});
if (result.length === 0) {
return res.status(404).json({ error: "Listing not found or already deleted" });
}
res.json({ message: "Listing deleted successfully", listing: result[0] });
} catch (err) {
console.error("Error deleting listing:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// Get the list of species
router.get("/species", async (req, res) => {
try {
const result = await select({
table: 'species',
where: { deleted: false },
orderBy: { column: 'name', direction: 'asc' }
});
res.json(result);
} catch (err) {
console.error("Error fetching species:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// Get the list of breeds
router.get("/breeds", async (req, res) => {
try {
// Parse and validate query parameters
const species_id = req.query.species_id;
// Build where conditions using JSON-based structure
const where = { deleted: false };
if (species_id) where.species_id = species_id;
// Use queryHelper select with JSON-based where conditions
const result = await select({
table: 'breeds',
where,
orderBy: { column: 'name', direction: 'asc' }
});
res.json(result);
} catch (err) {
console.error("Error fetching breeds:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// Get user favorite listings
router.get("/user/:userId/favorites", async (req, res) => {
try {
const { userId } = req.params;
const result = await execute({
type: 'raw-builder',
handler: async (knex) => {
return await knex('favorites as f')
.select('l.*', knex.raw('row_to_json(a) as animal'))
.join('listings as l', 'f.listing_id', 'l.id')
.join('animals as a', 'l.animal_id', 'a.id')
.where('f.user_id', userId)
.where('f.deleted', false)
.where('l.deleted', false)
.orderBy('f.created_at', 'desc');
}
});
res.json(result);
} catch (err) {
console.error("Error fetching favorite listings:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// Get listings created by user
router.get("/user/:userId", async (req, res) => {
try {
const { userId } = req.params;
const result = await execute({
type: 'raw-builder',
handler: async (knex) => {
return await knex('listings as l')
.select('l.*', knex.raw('row_to_json(a) as animal'))
.join('animals as a', 'l.animal_id', 'a.id')
.where('l.deleted', false)
.where('l.seller_id', userId)
.orderBy('l.created_at', 'desc');
}
});
res.json(result);
} catch (err) {
console.error("Error fetching user listings:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// Reactivate Expired Listing
router.put("/:id/relist", async (req, res) => {
try {
const { id } = req.params;
// Use queryHelper update with JSON-based where conditions
const result = await update({
table: 'listings',
data: {
status: 'active',
updated_at: new Date() // Will be converted to NOW() by database
},
where: {
id,
status: 'expired',
deleted: false
},
returning: '*'
});
if (result.length === 0) {
return res.status(404).json({ error: "Expired listing not found or already active" });
}
res.json(result[0]);
} catch (err) {
console.error("Error relisting item:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// Update listing score & status
router.patch("/:id/score", async (req, res) => {
try {
const { id } = req.params;
// Parse and extract update data from request body
const { listing_score, listing_score_status } = req.body;
// Build update data object using JSON-based structure (only include fields that are provided)
const updateData = {};
if (listing_score !== undefined) {
updateData.listing_score = parseFloat(listing_score);
}
if (listing_score_status !== undefined) {
updateData.listing_score_status = listing_score_status;
}
// Validate that at least one field is being updated
if (Object.keys(updateData).length === 0) {
return res.status(400).json({ error: "At least one field (listing_score or listing_score_status) must be provided" });
}
// Use queryHelper update with JSON-based where conditions
const result = await update({
table: 'listings',
data: updateData,
where: {
id,
deleted: false
},
returning: '*'
});
if (result.length === 0) {
return res.status(404).json({ error: "Listing not found" });
}
res.json(result[0]);
} catch (err) {
console.error("Error updating listing:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// Add Media to Listing
router.post("/:id/media", async (req, res) => {
try {
const { id } = req.params;
const { media } = req.body; // Array of { media_url, media_type, is_primary, sort_order }
if (!media || !Array.isArray(media) || media.length === 0) {
return res.status(400).json({ error: "Media array is required" });
}
const result = await execute({
type: 'transaction',
handler: async (trx) => {
// Check if listing exists
const listing = await trx('listings')
.select('id')
.where({ id, deleted: false })
.first();
if (!listing) {
throw new Error("Listing not found");
}
// Insert media
const mediaData = media.map(item => ({
listing_id: id,
media_url: item.media_url,
media_type: item.media_type,
is_primary: item.is_primary || false,
sort_order: item.sort_order || 0
}));
return await trx('listing_media')
.insert(mediaData)
.returning('*');
}
});
res.status(201).json(result);
} catch (err) {
if (err.message === "Listing not found") {
return res.status(404).json({ error: err.message });
}
console.error("Error adding media to listing:", err);
res.status(500).json({ error: "Internal server error" });
}
});
export default router;