Skip to content

Commit 36270dc

Browse files
committed
feat: add price alert webhooks for creator key thresholds
Register one-shot price alerts that fire a callback when a creator key price crosses a target threshold during indexer trade processing. - POST /alerts registers an alert ({ creator_id, wallet_address, target_price, direction, callback_url }) and returns a unique alert ID - DELETE /alerts/:id cancels a pending alert before it fires - Alerts are evaluated on the indexer trade-event path: an `above` alert fires when the new price rises to/past the target, a `below` alert when it drops to/past the target; opposite movement does not fire - Successful delivery deletes the alert (one-shot); failed delivery is retried with exponential backoff up to WEBHOOK_RETRY_MAX_ATTEMPTS, then the alert is marked FAILED - Adds an Alert Prisma model + migration; reuses the trade-webhook delivery/retry conventions and is fanned out alongside webhook dispatch via a single processTradeEvent entry point Closes #423
1 parent 7d98a5f commit 36270dc

12 files changed

Lines changed: 992 additions & 0 deletions

File tree

prisma/schema/alert.prisma

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// prisma/schema/alert.prisma
2+
3+
enum AlertDirection {
4+
ABOVE
5+
BELOW
6+
}
7+
8+
enum AlertStatus {
9+
PENDING
10+
TRIGGERED
11+
FAILED
12+
}
13+
14+
model Alert {
15+
id String @id @default(cuid())
16+
creatorId String
17+
walletAddress String
18+
targetPrice Decimal @db.Decimal(38, 18)
19+
direction AlertDirection
20+
callbackUrl String
21+
status AlertStatus @default(PENDING)
22+
retryCount Int @default(0)
23+
lastError String?
24+
triggeredAt DateTime?
25+
createdAt DateTime @default(now())
26+
updatedAt DateTime @updatedAt
27+
28+
@@index([creatorId])
29+
@@index([status])
30+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
-- CreateEnum
2+
CREATE TYPE "AlertDirection" AS ENUM ('ABOVE', 'BELOW');
3+
4+
-- CreateEnum
5+
CREATE TYPE "AlertStatus" AS ENUM ('PENDING', 'TRIGGERED', 'FAILED');
6+
7+
-- CreateTable
8+
CREATE TABLE "Alert" (
9+
"id" TEXT NOT NULL,
10+
"creatorId" TEXT NOT NULL,
11+
"walletAddress" TEXT NOT NULL,
12+
"targetPrice" DECIMAL(38,18) NOT NULL,
13+
"direction" "AlertDirection" NOT NULL,
14+
"callbackUrl" TEXT NOT NULL,
15+
"status" "AlertStatus" NOT NULL DEFAULT 'PENDING',
16+
"retryCount" INTEGER NOT NULL DEFAULT 0,
17+
"lastError" TEXT,
18+
"triggeredAt" TIMESTAMP(3),
19+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
20+
"updatedAt" TIMESTAMP(3) NOT NULL,
21+
22+
CONSTRAINT "Alert_pkey" PRIMARY KEY ("id")
23+
);
24+
25+
-- CreateIndex
26+
CREATE INDEX "Alert_creatorId_idx" ON "Alert"("creatorId");
27+
28+
-- CreateIndex
29+
CREATE INDEX "Alert_status_idx" ON "Alert"("status");
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { Request, Response } from 'express';
2+
import {
3+
sendSuccess,
4+
sendError,
5+
sendValidationError,
6+
sendNotFound,
7+
} from '../../utils/api-response.utils';
8+
import { ErrorCode } from '../../constants/error.constants';
9+
import { CreateAlertSchema } from './alert.schemas';
10+
import * as alertService from './alert.service';
11+
12+
export async function registerAlertHandler(
13+
req: Request,
14+
res: Response
15+
): Promise<void> {
16+
const parseResult = CreateAlertSchema.safeParse(req.body);
17+
if (!parseResult.success) {
18+
sendValidationError(
19+
res,
20+
'Invalid alert registration data',
21+
parseResult.error.issues.map((issue) => ({
22+
field: issue.path.join('.'),
23+
message: issue.message,
24+
}))
25+
);
26+
return;
27+
}
28+
29+
try {
30+
const result = await alertService.createAlert({
31+
creatorId: parseResult.data.creator_id,
32+
walletAddress: parseResult.data.wallet_address,
33+
targetPrice: parseResult.data.target_price,
34+
direction: parseResult.data.direction,
35+
callbackUrl: parseResult.data.callback_url,
36+
});
37+
sendSuccess(res, result, 201, 'Alert registered successfully');
38+
} catch {
39+
sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to register alert');
40+
}
41+
}
42+
43+
export async function deleteAlertHandler(
44+
req: Request,
45+
res: Response
46+
): Promise<void> {
47+
const rawAlertId = req.params.id;
48+
const alertId = Array.isArray(rawAlertId) ? rawAlertId[0] : rawAlertId;
49+
50+
if (!alertId) {
51+
sendError(res, 400, ErrorCode.BAD_REQUEST, 'Missing alert ID in path');
52+
return;
53+
}
54+
55+
try {
56+
const result = await alertService.deleteAlert(alertId);
57+
if (!result) {
58+
sendNotFound(res, 'Alert');
59+
return;
60+
}
61+
sendSuccess(res, result, 200, 'Alert cancelled successfully');
62+
} catch {
63+
sendError(res, 500, ErrorCode.INTERNAL_ERROR, 'Failed to cancel alert');
64+
}
65+
}
Lines changed: 244 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,244 @@
1+
import supertest from 'supertest';
2+
import { Prisma } from '@prisma/client';
3+
import { Keypair } from '@stellar/stellar-base';
4+
5+
// Mock Prisma so the integration test exercises the full HTTP + dispatch path
6+
// without requiring a live database, matching the suite's mocking conventions.
7+
jest.mock('../../utils/prisma.utils', () => ({
8+
prisma: {
9+
alert: {
10+
create: jest.fn(),
11+
findFirst: jest.fn(),
12+
findMany: jest.fn(),
13+
delete: jest.fn(),
14+
update: jest.fn(),
15+
},
16+
},
17+
}));
18+
19+
jest.mock('../../utils/logger.utils', () => ({
20+
logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
21+
}));
22+
23+
import app from '../../app';
24+
import { prisma } from '../../utils/prisma.utils';
25+
import { evaluateTradeForAlerts } from './alert.service';
26+
import { envConfig } from '../../config';
27+
28+
const mockPrisma = prisma as unknown as {
29+
alert: {
30+
create: jest.Mock;
31+
findFirst: jest.Mock;
32+
findMany: jest.Mock;
33+
delete: jest.Mock;
34+
update: jest.Mock;
35+
};
36+
};
37+
38+
const walletAddress = Keypair.random().publicKey();
39+
40+
function decimal(v: string): Prisma.Decimal {
41+
return new Prisma.Decimal(v);
42+
}
43+
44+
beforeEach(() => {
45+
jest.clearAllMocks();
46+
});
47+
48+
describe('POST /api/v1/alerts', () => {
49+
it('registers an alert and returns a unique alert ID', async () => {
50+
mockPrisma.alert.create.mockResolvedValue({
51+
id: 'alert-generated-id',
52+
creatorId: 'creator-1',
53+
walletAddress,
54+
targetPrice: decimal('15'),
55+
direction: 'ABOVE',
56+
callbackUrl: 'https://example.com/hook',
57+
status: 'PENDING',
58+
createdAt: new Date(),
59+
});
60+
61+
const res = await supertest(app)
62+
.post('/api/v1/alerts')
63+
.send({
64+
creator_id: 'creator-1',
65+
wallet_address: walletAddress,
66+
target_price: '15',
67+
direction: 'above',
68+
callback_url: 'https://example.com/hook',
69+
});
70+
71+
expect(res.status).toBe(201);
72+
expect(res.body.success).toBe(true);
73+
expect(res.body.data.id).toBe('alert-generated-id');
74+
expect(res.body.data.direction).toBe('above');
75+
});
76+
77+
it('returns 400 on invalid body (bad direction / url / price)', async () => {
78+
const res = await supertest(app)
79+
.post('/api/v1/alerts')
80+
.send({
81+
creator_id: 'creator-1',
82+
wallet_address: walletAddress,
83+
target_price: '-5',
84+
direction: 'sideways',
85+
callback_url: 'not-a-url',
86+
});
87+
88+
expect(res.status).toBe(400);
89+
expect(mockPrisma.alert.create).not.toHaveBeenCalled();
90+
});
91+
});
92+
93+
describe('DELETE /api/v1/alerts/:id', () => {
94+
it('cancels a pending alert before it fires', async () => {
95+
mockPrisma.alert.findFirst.mockResolvedValue({ id: 'alert-1', status: 'PENDING' });
96+
mockPrisma.alert.delete.mockResolvedValue({ id: 'alert-1' });
97+
98+
const res = await supertest(app).delete('/api/v1/alerts/alert-1');
99+
100+
expect(res.status).toBe(200);
101+
expect(res.body.success).toBe(true);
102+
expect(mockPrisma.alert.delete).toHaveBeenCalledWith({ where: { id: 'alert-1' } });
103+
});
104+
105+
it('returns 404 for a non-existent alert', async () => {
106+
mockPrisma.alert.findFirst.mockResolvedValue(null);
107+
108+
const res = await supertest(app).delete('/api/v1/alerts/missing-id');
109+
110+
expect(res.status).toBe(404);
111+
});
112+
});
113+
114+
describe('alert trigger evaluation', () => {
115+
afterEach(() => {
116+
jest.restoreAllMocks();
117+
});
118+
119+
it('fires on an above trigger and deletes the alert (one-shot)', async () => {
120+
mockPrisma.alert.findMany.mockResolvedValue([
121+
{
122+
id: 'alert-above',
123+
creatorId: 'creator-1',
124+
walletAddress,
125+
targetPrice: decimal('10'),
126+
direction: 'ABOVE',
127+
callbackUrl: 'https://example.com/hook',
128+
status: 'PENDING',
129+
},
130+
]);
131+
mockPrisma.alert.delete.mockResolvedValue({ id: 'alert-above' });
132+
const mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, statusText: 'OK' });
133+
(global.fetch as jest.Mock) = mockFetch;
134+
135+
await evaluateTradeForAlerts({
136+
creatorId: 'creator-1',
137+
price: '11',
138+
timestamp: new Date().toISOString(),
139+
});
140+
141+
expect(mockFetch).toHaveBeenCalledTimes(1);
142+
expect(mockPrisma.alert.delete).toHaveBeenCalledWith({ where: { id: 'alert-above' } });
143+
});
144+
145+
it('fires on a below trigger and deletes the alert (one-shot)', async () => {
146+
mockPrisma.alert.findMany.mockResolvedValue([
147+
{
148+
id: 'alert-below',
149+
creatorId: 'creator-1',
150+
walletAddress,
151+
targetPrice: decimal('10'),
152+
direction: 'BELOW',
153+
callbackUrl: 'https://example.com/hook',
154+
status: 'PENDING',
155+
},
156+
]);
157+
mockPrisma.alert.delete.mockResolvedValue({ id: 'alert-below' });
158+
const mockFetch = jest.fn().mockResolvedValue({ ok: true, status: 200, statusText: 'OK' });
159+
(global.fetch as jest.Mock) = mockFetch;
160+
161+
await evaluateTradeForAlerts({
162+
creatorId: 'creator-1',
163+
price: '9',
164+
timestamp: new Date().toISOString(),
165+
});
166+
167+
expect(mockFetch).toHaveBeenCalledTimes(1);
168+
expect(mockPrisma.alert.delete).toHaveBeenCalledWith({ where: { id: 'alert-below' } });
169+
});
170+
171+
it('does not fire when price moves in the opposite direction', async () => {
172+
mockPrisma.alert.findMany.mockResolvedValue([
173+
{
174+
id: 'alert-above',
175+
creatorId: 'creator-1',
176+
walletAddress,
177+
targetPrice: decimal('10'),
178+
direction: 'ABOVE',
179+
callbackUrl: 'https://example.com/hook',
180+
status: 'PENDING',
181+
},
182+
]);
183+
const mockFetch = jest.fn();
184+
(global.fetch as jest.Mock) = mockFetch;
185+
186+
await evaluateTradeForAlerts({
187+
creatorId: 'creator-1',
188+
price: '5',
189+
timestamp: new Date().toISOString(),
190+
});
191+
192+
expect(mockFetch).not.toHaveBeenCalled();
193+
expect(mockPrisma.alert.delete).not.toHaveBeenCalled();
194+
});
195+
196+
describe('failed delivery retry', () => {
197+
beforeEach(() => {
198+
jest.useFakeTimers();
199+
});
200+
201+
afterEach(() => {
202+
jest.useRealTimers();
203+
});
204+
205+
it('retries failed delivery up to 3 times then marks the alert failed', async () => {
206+
mockPrisma.alert.findMany.mockResolvedValue([
207+
{
208+
id: 'alert-fail',
209+
creatorId: 'creator-1',
210+
walletAddress,
211+
targetPrice: decimal('10'),
212+
direction: 'ABOVE',
213+
callbackUrl: 'https://nonexistent.example.com/fail',
214+
status: 'PENDING',
215+
},
216+
]);
217+
mockPrisma.alert.update.mockResolvedValue({});
218+
const mockFetch = jest.fn().mockRejectedValue(new Error('Network error'));
219+
(global.fetch as jest.Mock) = mockFetch;
220+
221+
const promise = evaluateTradeForAlerts({
222+
creatorId: 'creator-1',
223+
price: '20',
224+
timestamp: new Date().toISOString(),
225+
});
226+
227+
for (let i = 0; i < envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS; i++) {
228+
await jest.advanceTimersByTimeAsync(
229+
Math.pow(2, i) * envConfig.WEBHOOK_RETRY_BASE_DELAY_MS
230+
);
231+
}
232+
233+
await promise;
234+
235+
expect(mockFetch).toHaveBeenCalledTimes(envConfig.WEBHOOK_RETRY_MAX_ATTEMPTS);
236+
expect(mockPrisma.alert.delete).not.toHaveBeenCalled();
237+
expect(mockPrisma.alert.update).toHaveBeenLastCalledWith(
238+
expect.objectContaining({
239+
data: expect.objectContaining({ status: 'FAILED' }),
240+
})
241+
);
242+
});
243+
});
244+
});

src/modules/alerts/alert.router.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Router } from 'express';
2+
import { registerAlertHandler, deleteAlertHandler } from './alert.controllers';
3+
4+
const router = Router();
5+
6+
router.post('/', registerAlertHandler);
7+
router.delete('/:id', deleteAlertHandler);
8+
9+
export default router;

0 commit comments

Comments
 (0)