Added scheduler for listing expiry after 48hrs

This commit is contained in:
Soham Chari 2025-12-19 11:58:21 +05:30
parent 7534605f19
commit 24a89c71d2
5 changed files with 172 additions and 8 deletions

71
jobs/expirationJob.js Normal file
View File

@ -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();
}
});
};

9
package-lock.json generated
View File

@ -12,6 +12,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.1.0", "express": "^5.1.0",
"node-cron": "^4.2.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"socket.io": "^4.8.1" "socket.io": "^4.8.1"
} }
@ -610,6 +611,14 @@
"node": ">= 0.6" "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": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",

View File

@ -21,6 +21,7 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^5.1.0", "express": "^5.1.0",
"node-cron": "^4.2.1",
"pg": "^8.16.3", "pg": "^8.16.3",
"socket.io": "^4.8.1" "socket.io": "^4.8.1"
} }

View File

@ -380,11 +380,18 @@ router.get("/:id", async (req, res) => {
// UPDATE Listing // UPDATE Listing
router.put("/:id", async (req, res) => { router.put("/:id", async (req, res) => {
const client = await pool.connect();
try { try {
const { id } = req.params; 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 UPDATE listings
SET title = COALESCE($1, title), SET title = COALESCE($1, title),
price = COALESCE($2, price), price = COALESCE($2, price),
@ -395,19 +402,67 @@ router.put("/:id", async (req, res) => {
WHERE id = $7 AND deleted = FALSE WHERE id = $7 AND deleted = FALSE
RETURNING * 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 (listingResult.rows.length === 0) {
await client.query("ROLLBACK");
if (result.rows.length === 0) {
return res.status(404).json({ error: "Listing not found" }); 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) { } catch (err) {
await client.query("ROLLBACK");
console.error("Error updating listing:", err); console.error("Error updating listing:", err);
res.status(500).json({ error: "Internal server error" }); res.status(500).json({ error: "Internal server error" });
} } finally {
client.release();
}
}); });
// SOFT DELETE Listing // 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 // Update listing score & status
router.patch("/:id/score", async (req, res) => { router.patch("/:id/score", async (req, res) => {
try { try {

View File

@ -18,10 +18,14 @@ app.use("/locations", locationRoutes);
app.use("/chat", chatRoutes); app.use("/chat", chatRoutes);
import http from "http"; import http from "http";
import { initSocket } from "./socket.js"; import { initSocket } from "./socket.js";
import { startExpirationJob } from "./jobs/expirationJob.js";
const server = http.createServer(app); const server = http.createServer(app);
initSocket(server); initSocket(server);
// Start Background Jobs
startExpirationJob();
server.listen(PORT, () => { server.listen(PORT, () => {
console.log(`BuySellService is running on port ${PORT}`); console.log(`BuySellService is running on port ${PORT}`);
}); });