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;