mirror of
https://github.com/Open-Dev-Society/OpenStock.git
synced 2026-10-02 04:54:32 +08:00
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:
co-authored by
Claude Opus 5.5
parent
f1045c2ab4
commit
1edee51473
@@ -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,
|
||||
})
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
Generated
+1101
-697
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user