Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions prisma/schema/alert.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ model PriceAlert {
callbackUrl String? // optional since #1012
isActive Boolean @default(true)
triggeredAt DateTime?
triggeredPrice Decimal? @db.Decimal(20, 7)
createdAt DateTime @default(now())

@@index([creatorId])
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "PriceAlert"
ADD COLUMN "triggeredPrice" DECIMAL(20, 7);
1 change: 1 addition & 0 deletions src/constants/notifications.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
TRADE_COMPLETED: 'trade_completed',
LOCKUP_EXPIRING: 'lockup_expiring',
PRICE_MOVED: 'price_moved',
PRICE_ALERT_TRIGGERED: 'price_alert_triggered',
PAUSE_PROPOSAL_CREATED: 'pause_proposal_created',
TRADING_PAUSED: 'trading_paused',
KEY_DEPRECATED: 'key_deprecated',
KEY_SUNSET_FLAGGED: 'key_sunset_flagged',
MILESTONE_CROSSED: 'milestone_crossed',
CIRCUIT_BREAKER_TRIPPED: 'circuit_breaker_tripped',
PRICE_ALERT_TRIGGERED: 'price_alert_triggered',

Check failure on line 14 in src/constants/notifications.constants.ts

View workflow job for this annotation

GitHub Actions / verify

An object literal cannot have multiple properties with the same name.
} as const;

export type NotificationType =
Expand Down
3 changes: 2 additions & 1 deletion src/modules/alerts/alert.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ export async function httpListAlerts(
): Promise<void> {
try {
const authWallet = (req as AuthenticatedRequest).user?.wallet;
const queryWallet = req.query.wallet_address || req.query.walletAddress;
const queryWallet =
req.query.wallet || req.query.wallet_address || req.query.walletAddress;
const targetWallet = authWallet || queryWallet;

if (!targetWallet || typeof targetWallet !== 'string') {
Expand Down
11 changes: 9 additions & 2 deletions src/modules/alerts/alert.schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { isValidStellarAddress } from '../wallet/wallet.utils';

export const CreateAlertSchema = z
.object({
key_id: z.string().min(1, 'key_id is required').optional(),
keyId: z.string().min(1, 'keyId is required').optional(),
creator_id: z.string().min(1, 'creator_id is required').optional(),
creatorId: z.string().min(1, 'creatorId is required').optional(),
Expand Down Expand Up @@ -34,7 +35,7 @@ export const CreateAlertSchema = z
callbackUrl: z.string().url('callbackUrl must be a valid URL').optional(),
})
.refine(
(data) => !!(data.keyId || data.creator_id || data.creatorId),
(data) => !!(data.key_id || data.keyId || data.creator_id || data.creatorId),
{
message: 'keyId is required',
path: ['keyId'],
Expand All @@ -48,7 +49,7 @@ export const CreateAlertSchema = z
}
)
.transform((data) => ({
creator_id: (data.keyId || data.creator_id || data.creatorId)!,
creator_id: (data.key_id || data.keyId || data.creator_id || data.creatorId)!,
wallet_address: data.wallet_address || data.walletAddress,
target_price: (data.targetPrice ?? data.target_price)!,
direction: data.direction,
Expand All @@ -67,6 +68,12 @@ export type CreateAlertInput = {
};

export const ListAlertsQuerySchema = z.object({
wallet: z
.string()
.refine(isValidStellarAddress, {
message: 'Invalid Stellar wallet address',
})
.optional(),
wallet_address: z
.string()
.refine(isValidStellarAddress, {
Expand Down
130 changes: 63 additions & 67 deletions src/modules/alerts/alert.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { logger } from '../../utils/logger.utils';
import { CreateAlertInput } from './alert.schemas';
import { truncateWallet } from '../../utils/wallet-display.utils';
import { SendMail } from '../../utils/mail.utils';

export type PriceMovement = {
creatorId: string;
Expand Down Expand Up @@ -42,7 +43,7 @@
walletAddress: input.wallet_address,
targetPrice: input.target_price,
direction: input.direction,
callbackUrl: input.callback_url ?? null,
callbackUrl: input.callback_url,
},
});

Expand Down Expand Up @@ -71,21 +72,6 @@
});
}

/**
* Issue #1012: returns every triggered (not-necessarily-active) alert for a
* wallet so the notifications service can surface them in-app. Ordered by
* triggeredAt desc so newest fires first.
*/
export async function listTriggeredAlertsForWallet(walletAddress: string) {
return await prisma.priceAlert.findMany({
where: {
walletAddress,
triggeredAt: { not: null },
},
orderBy: { triggeredAt: 'desc' },
});
}

/**
* Deletes a price alert by id, scoped to the wallet address for authorization.
* Returns the deleted record id or null if not found.
Expand Down Expand Up @@ -179,6 +165,8 @@
return typeof value === 'number' ? value : Number(value.toString());
}



function maskCallbackUrl(callbackUrl: string): string {
try {
const url = new URL(callbackUrl);
Expand All @@ -203,17 +191,13 @@
walletAddress: string;
targetPrice: unknown;
direction: string;
callbackUrl: string | null;
callbackUrl: string;
},
payload: Record<string, unknown>
): Promise<void> {
if (!alert.callbackUrl) {
return;
}

const maxAttempts = envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS;
const maskedUrl = maskCallbackUrl(alert.callbackUrl);
const threshold = toNumber(alert.targetPrice as string | number | { toString(): string});
const threshold = toNumber(alert.targetPrice as string | number | { toString(): string });
const triggeredPrice = toNumber(payload.current_price as string | number | { toString(): string });

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
Expand Down Expand Up @@ -280,12 +264,6 @@
/**
* Evaluates active alerts for a creator price movement and delivers only alerts
* whose threshold was crossed in the registered direction.
*
* Issue #1012: alert disable is unconditional — even if webhook delivery
* throws, the alert is disabled and (via listTriggeredAlertsForWallet) becomes
* visible in the in-app notification feed. Webhook failure is logged, not
* propagated, so callers wiring this into the price pipeline can't roll back
* a trade.
*/
export async function evaluatePriceAlertsForMovement(
movement: PriceMovement
Expand All @@ -302,8 +280,6 @@
},
});

let alertsTriggered = 0;

for (const alert of alerts) {
const targetPrice = toNumber(alert.targetPrice);
const crossedAbove =
Expand All @@ -319,53 +295,60 @@
continue;
}

// Disable + record trigger first — this is what the in-app
// notification reads. If this succeeds, the user sees the alert even
// if the webhook below fails.
await prisma.priceAlert.update({
where: { id: alert.id },
// Claim the alert atomically before delivery so overlapping indexer
// workers cannot notify the same wallet more than once.
const claimed = await prisma.priceAlert.updateMany({
where: { id: alert.id, isActive: true, triggeredAt: null },
data: {
isActive: false,
triggeredAt: new Date(),
triggeredPrice: String(movement.currentPrice),
},
});
alertsTriggered += 1;
if (claimed.count === 0) continue;

const payload = {
event_type: 'price_alert',
alert_id: alert.id,
creator_id: alert.creatorId,
wallet_address: alert.walletAddress,
target_price: targetPrice,
current_price: currentPrice,
direction: alert.direction,
};

try {
await deliverPriceAlertWebhook(alert, {
event_type: 'price_alert',
alert_id: alert.id,
creator_id: alert.creatorId,
wallet_address: alert.walletAddress,
target_price: targetPrice,
current_price: currentPrice,
direction: alert.direction,
await deliverPriceAlertWebhook(alert, payload);

Check failure on line 321 in src/modules/alerts/alert.service.ts

View workflow job for this annotation

GitHub Actions / verify

Argument of type '{ id: string; createdAt: Date; walletAddress: string; creatorId: string; direction: string; callbackUrl: string | null; isActive: boolean; targetPrice: Decimal; triggeredAt: Date | null; triggeredPrice: Decimal | null; }' is not assignable to parameter of type '{ id: string; creatorId: string; walletAddress: string; targetPrice: unknown; direction: string; callbackUrl: string; }'.
} catch (error) {
// The trigger is already durable and visible in the in-app inbox;
// a webhook outage must not reactivate it or block other alerts.
logger.warn(
{ alert_id: alert.id, error: getDeliveryErrorCode(error) },
'Price alert webhook delivery failed after trigger'
);
}

try {
const wallet = await prisma.stellarWallet.findUnique({
where: { address: alert.walletAddress },
select: { user: { select: { email: true, emailVerified: true } } },
});
} catch (webhookError) {
logger.error(
{
alert_id: alert.id,
creator_id: alert.creatorId,
error_message:
webhookError instanceof Error
? webhookError.message
: 'Unknown error',
},
'Price alert webhook failed after alert was disabled'
if (wallet?.user.emailVerified) {
const safeKey = escapeHtml(alert.creatorId);
await SendMail({
to: wallet.user.email,
subject: `Price alert triggered for ${alert.creatorId}`,
text: `The ${alert.direction} price alert for key ${alert.creatorId} triggered at ${currentPrice}. Your target was ${targetPrice}.`,
html: `<p>Your ${escapeHtml(alert.direction)} price alert for key <strong>${safeKey}</strong> triggered.</p><p>Current price: <strong>${currentPrice}</strong><br>Alert target: <strong>${targetPrice}</strong></p>`,
});
}
} catch (error) {
logger.warn(
{ alert_id: alert.id, error: (error as Error).message },
'Price alert email delivery failed'
);
}
}

logger[alertsTriggered > 0 ? 'info' : 'debug'](
{
creator_id: movement.creatorId,
alerts_evaluated: alerts.length,
alerts_triggered: alertsTriggered,
new_price: currentPrice,
checked_at: new Date().toISOString(),
},
'Price alert check run'
);
} catch (err) {
logger.error(
{
Expand All @@ -379,4 +362,17 @@
);
throw err;
}
}
}

function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, character => {
const entities: Record<string, string> = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
return entities[character] ?? character;
});
}
15 changes: 15 additions & 0 deletions src/modules/indexer/price-snapshot.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,21 @@ export async function upsertPriceSnapshot(
},
}),
]);
try {
const { evaluatePriceAlertsForMovement } = await import('../alerts/alert.service');
await evaluatePriceAlertsForMovement({
creatorId,
previousPrice: existing.currentPrice.toString(),
currentPrice: price.toString(),
ledger_sequence: ledger,
});
} catch (error) {
// Price history is the source of truth; alert delivery is best-effort.
logger.warn(
{ creator_id: creatorId, error: (error as Error).message },
'price-snapshot: alert evaluation failed'
);
}
logger.debug(
{
creator_id: creatorId,
Expand Down
Loading
Loading