fix(jobs): upgrade Inngest to v4 so background jobs register; email alert owners

functions.ts used the v4 createFunction signature on the v3 package, so all four
jobs registered with empty triggers and never ran. Triggered price alerts now email
their owner instead of only logging; the alert job runs one at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ravixalgorithm
2026-09-25 15:28:00 +05:30
co-authored by Claude Opus 5.5
parent f1045c2ab4
commit 1edee51473
5 changed files with 1178 additions and 708 deletions
-1
View File
@@ -2,7 +2,6 @@ import {Inngest} from "inngest"
export const inngest = new Inngest({
id: "openStock",
ai: {gemini: {apiKey: process.env.GEMINI_API_KEY}},
// Add signing key for Vercel deployment
signingKey: process.env.INNGEST_SIGNING_KEY,
})
+34 -8
View File
@@ -1,6 +1,6 @@
import { inngest } from "@/lib/inngest/client";
import { NEWS_SUMMARY_EMAIL_PROMPT, PERSONALIZED_WELCOME_EMAIL_PROMPT } from "@/lib/inngest/prompts";
import { sendNewsSummaryEmail, sendWelcomeEmail } from "@/lib/nodemailer";
import { sendNewsSummaryEmail, sendStockAlertEmail, sendWelcomeEmail } from "@/lib/nodemailer";
import { getAllUsersForNewsEmail } from "@/lib/actions/user.actions";
import { getWatchlistSymbolsByEmail } from "@/lib/actions/watchlist.actions";
import { getNews } from "@/lib/actions/finnhub.actions";
@@ -203,7 +203,7 @@ export const sendWeeklyNewsSummary = inngest.createFunction(
)
export const checkStockAlerts = inngest.createFunction(
{ id: 'check-stock-alerts', triggers: [{ cron: '*/5 * * * *' }] }, // Run every 5 minutes
{ id: 'check-stock-alerts', concurrency: 1, triggers: [{ cron: '*/5 * * * *' }] }, // Every 5 minutes; one run at a time so an alert is never emailed twice
async ({ step }) => {
// Step 1: Fetch active alerts
const activeAlerts = await step.run('fetch-active-alerts', async () => {
@@ -268,20 +268,46 @@ export const checkStockAlerts = inngest.createFunction(
}
}
// Step 5: Process triggers
// Step 5: Email the alert owner, then mark triggered
if (triggeredAlerts.length > 0) {
await step.run('process-triggered-alerts', async () => {
const { connectToDatabase } = await import("@/database/mongoose");
const { Alert } = await import("@/database/models/alert.model");
// In a real app we would import 'kit' here and use kit.sendBroadcast or similar
// For now, we just log it as the critical logic is the detection
await connectToDatabase();
const mongoose = await connectToDatabase();
const db = mongoose.connection.db;
if (!db) throw new Error("No DB Connection");
for (const { alert, currentPrice } of triggeredAlerts) {
console.log(`🚀 ALERT FIRED: ${alert.symbol} is ${currentPrice} (${alert.condition} ${alert.targetPrice})`);
// Mark triggered
await Alert.findByIdAndUpdate(alert._id, { triggered: true, active: false });
// Per-alert try/catch: a failure leaves the alert active so the next 5-min run retries it,
// and never throws the step (a step retry would re-email alerts already sent in this loop).
try {
// Better Auth users may be keyed by `id` or `_id` (see getWatchlistSymbolsByEmail)
const user = await db.collection('user').findOne<{ email?: string }>(
mongoose.isValidObjectId(alert.userId)
? { $or: [{ id: alert.userId }, { _id: new mongoose.Types.ObjectId(alert.userId) }] }
: { id: alert.userId }
);
if (user?.email) {
const result = await sendStockAlertEmail({
email: user.email,
symbol: alert.symbol,
currentPrice,
targetPrice: alert.targetPrice,
condition: alert.condition,
});
// Email not configured: keep the alert active so it fires once email works
if (result.status === 'skipped') continue;
} else {
console.warn(`⚠️ No email for user ${alert.userId}; closing alert ${alert._id} without notifying`);
}
await Alert.findByIdAndUpdate(alert._id, { triggered: true, active: false });
} catch (error) {
console.error(`❌ Failed to process alert ${alert._id} (${alert.symbol}); will retry next run`, error);
}
}
});
}
+42 -1
View File
@@ -1,5 +1,11 @@
import nodemailer from 'nodemailer';
import { WELCOME_EMAIL_TEMPLATE, NEWS_SUMMARY_EMAIL_TEMPLATE } from "@/lib/nodemailer/templates";
import {
WELCOME_EMAIL_TEMPLATE,
NEWS_SUMMARY_EMAIL_TEMPLATE,
STOCK_ALERT_UPPER_EMAIL_TEMPLATE,
STOCK_ALERT_LOWER_EMAIL_TEMPLATE,
} from "@/lib/nodemailer/templates";
import { formatPrice } from "@/lib/utils";
type EmailSendResult =
| { status: 'skipped' }
@@ -92,3 +98,38 @@ export const sendNewsSummaryEmail = async (
throw error;
}
};
export const sendStockAlertEmail = async (
{ email, symbol, currentPrice, targetPrice, condition }:
{ email: string; symbol: string; currentPrice: number; targetPrice: number; condition: 'ABOVE' | 'BELOW' }
) => {
try {
if (!transporter) {
console.warn('⚠️ Stock alert email skipped: email credentials are not configured.');
return { status: 'skipped' } satisfies EmailSendResult;
}
const isUpper = condition === 'ABOVE';
const htmlTemplate = (isUpper ? STOCK_ALERT_UPPER_EMAIL_TEMPLATE : STOCK_ALERT_LOWER_EMAIL_TEMPLATE)
.replaceAll('{{symbol}}', symbol)
.replaceAll('{{company}}', '') // Alerts don't store the company name
.replaceAll('{{currentPrice}}', formatPrice(currentPrice))
.replaceAll('{{targetPrice}}', formatPrice(targetPrice))
.replaceAll('{{timestamp}}', new Date().toUTCString());
const mailOptions = {
from: `"Openstock" <${process.env.NODEMAILER_EMAIL}>`,
to: email,
subject: `🔔 Price Alert: ${symbol} is ${isUpper ? 'above' : 'below'} ${formatPrice(targetPrice)}`,
text: `${symbol} is now ${formatPrice(currentPrice)}, ${isUpper ? 'above' : 'below'} your target of ${formatPrice(targetPrice)}.`,
html: htmlTemplate,
};
const info = await transporter.sendMail(mailOptions);
console.log('✅ Stock alert email sent successfully:', info.messageId);
return { status: 'sent', messageId: info.messageId } satisfies EmailSendResult;
} catch (error) {
console.error('❌ Failed to send stock alert email:', error);
throw error;
}
};
+1101 -697
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -27,7 +27,7 @@
"country-data-list": "^1.5.5",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"inngest": "^3.47.0",
"inngest": "^4.21.0",
"lucide-react": "^0.544.0",
"mongodb": "^6.20.0",
"mongoose": "^8.19.0",