diff --git a/jobs/expirationJob.js b/jobs/expirationJob.js new file mode 100644 index 0000000..a3fd4a1 --- /dev/null +++ b/jobs/expirationJob.js @@ -0,0 +1,71 @@ +import schedule from "node-cron"; +import pool from "../db/pool.js"; +import { getIO, getSocketId } from "../socket.js"; + +// Run every hour +export const startExpirationJob = () => { + schedule.schedule("0 * * * *", async () => { + console.log("Running listing expiration check..."); + const client = await pool.connect(); + try { + await client.query("BEGIN"); + + // 1. Identify expired listings (active & not updated in last 48h) + // Using INTERVAL '48 hours' + const findExpiredQuery = ` + SELECT id, title, seller_id + FROM listings + WHERE status = 'active' + AND updated_at < NOW() - INTERVAL '48 hours' + AND deleted = FALSE + FOR UPDATE SKIP LOCKED + `; + const { rows: expiredListings } = await client.query(findExpiredQuery); + + if (expiredListings.length === 0) { + await client.query("COMMIT"); + return; + } + + console.log(`Found ${expiredListings.length} listings to expire.`); + + // 2. Update status to 'expired' + const expiredIds = expiredListings.map(l => l.id); + await client.query(` + UPDATE listings + SET status = 'expired' + WHERE id = ANY($1::uuid[]) + `, [expiredIds]); + + // 3. Create Notifications & Real-time Alerts + for (const listing of expiredListings) { + const message = `Your listing "${listing.title}" has expired after 48 hours of inactivity. Click here to re-list it.`; + + // Insert Notification + await client.query(` + INSERT INTO notifications (user_id, type, message, data) + VALUES ($1, 'listing_expired', $2, $3) + `, [listing.seller_id, message, { listing_id: listing.id }]); + + // Real-time Socket Emit + const socketId = getSocketId(listing.seller_id); + if (socketId) { + getIO().to(socketId).emit("notification", { + type: "listing_expired", + message, + data: { listing_id: listing.id } + }); + } + } + + await client.query("COMMIT"); + console.log("Expiration check completed successfully."); + + } catch (err) { + await client.query("ROLLBACK"); + console.error("Error in expiration job:", err); + } finally { + client.release(); + } + }); +}; diff --git a/package-lock.json b/package-lock.json index b6d5be0..d5fb123 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "node-cron": "^4.2.1", "pg": "^8.16.3", "socket.io": "^4.8.1" } @@ -610,6 +611,14 @@ "node": ">= 0.6" } }, + "node_modules/node-cron": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz", + "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/package.json b/package.json index 7912985..907cc71 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^5.1.0", + "node-cron": "^4.2.1", "pg": "^8.16.3", "socket.io": "^4.8.1" } diff --git a/routes/listingRoutes.js b/routes/listingRoutes.js index bcb61e2..e6e3124 100644 --- a/routes/listingRoutes.js +++ b/routes/listingRoutes.js @@ -380,11 +380,18 @@ router.get("/:id", async (req, res) => { // UPDATE Listing router.put("/:id", async (req, res) => { + const client = await pool.connect(); try { const { id } = req.params; - const { title, price, currency, is_negotiable, listing_type, status } = req.body; + const { + title, price, currency, is_negotiable, listing_type, status, + animal // Animal object + } = req.body; - const queryText = ` + await client.query("BEGIN"); + + // 1. Update Listing + const updateListingQuery = ` UPDATE listings SET title = COALESCE($1, title), price = COALESCE($2, price), @@ -395,19 +402,67 @@ router.put("/:id", async (req, res) => { WHERE id = $7 AND deleted = FALSE RETURNING * `; - const queryParams = [title, price, currency, is_negotiable, listing_type, status, id]; + const listingParams = [title, price, currency, is_negotiable, listing_type, status, id]; + const listingResult = await client.query(updateListingQuery, listingParams); - const result = await pool.query(queryText, queryParams); - - if (result.rows.length === 0) { + if (listingResult.rows.length === 0) { + await client.query("ROLLBACK"); return res.status(404).json({ error: "Listing not found" }); } - res.json(result.rows[0]); + const listing = listingResult.rows[0]; + + // 2. Update Animal (if provided) + if (animal) { + const updateAnimalQuery = ` + UPDATE animals + SET species_id = COALESCE($1, species_id), + breed_id = COALESCE($2, breed_id), + sex = COALESCE($3, sex), + age_months = COALESCE($4, age_months), + weight_kg = COALESCE($5, weight_kg), + color_markings = COALESCE($6, color_markings), + quantity = COALESCE($7, quantity), + purpose = COALESCE($8, purpose), + health_status = COALESCE($9, health_status), + vaccinated = COALESCE($10, vaccinated), + dewormed = COALESCE($11, dewormed), + pregnancy_status = COALESCE($12, pregnancy_status), + calving_number = COALESCE($13, calving_number), + milk_yield_litre_per_day = COALESCE($14, milk_yield_litre_per_day), + ear_tag_no = COALESCE($15, ear_tag_no), + description = COALESCE($16, description) + WHERE id = $17 + RETURNING * + `; + const animalParams = [ + animal.species_id, animal.breed_id, animal.sex, animal.age_months, + animal.weight_kg, animal.color_markings, animal.quantity, animal.purpose, + animal.health_status, animal.vaccinated, animal.dewormed, animal.pregnancy_status, + animal.calving_number, animal.milk_yield_litre_per_day, animal.ear_tag_no, animal.description, + listing.animal_id + ]; + await client.query(updateAnimalQuery, animalParams); + } + + await client.query("COMMIT"); + + // Fetch complete updated data + const completeResult = await client.query(` + SELECT l.*, row_to_json(a) as animal + FROM listings l + JOIN animals a ON l.animal_id = a.id + WHERE l.id = $1 + `, [id]); + + res.json(completeResult.rows[0]); } catch (err) { + await client.query("ROLLBACK"); console.error("Error updating listing:", err); res.status(500).json({ error: "Internal server error" }); - } + } finally { + client.release(); + } }); // SOFT DELETE Listing @@ -488,6 +543,30 @@ router.get("/user/:userId", async (req, res) => { } }); +// Reactivate Expired Listing +router.put("/:id/relist", async (req, res) => { + try { + const { id } = req.params; + + const queryText = ` + UPDATE listings + SET status = 'active', updated_at = NOW() + WHERE id = $1 AND status = 'expired' + RETURNING * + `; + const result = await pool.query(queryText, [id]); + + if (result.rows.length === 0) { + return res.status(404).json({ error: "Expired listing not found or already active" }); + } + + res.json(result.rows[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 { diff --git a/server.js b/server.js index 792c4c1..b72fe81 100644 --- a/server.js +++ b/server.js @@ -18,10 +18,14 @@ app.use("/locations", locationRoutes); app.use("/chat", chatRoutes); import http from "http"; import { initSocket } from "./socket.js"; +import { startExpirationJob } from "./jobs/expirationJob.js"; const server = http.createServer(app); initSocket(server); +// Start Background Jobs +startExpirationJob(); + server.listen(PORT, () => { console.log(`BuySellService is running on port ${PORT}`); });