Skip to content
Merged
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
16 changes: 16 additions & 0 deletions prisma/schema/alert.prisma
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// prisma/schema/alert.prisma

model PriceAlert {
id String @id @default(cuid())
creatorId String
walletAddress String
targetPrice Decimal
direction String // "above" | "below"
callbackUrl String
isActive Boolean @default(true)
triggeredAt DateTime?
createdAt DateTime @default(now())

@@index([creatorId])
@@index([walletAddress])
}
134 changes: 134 additions & 0 deletions src/modules/alerts/__tests__/alert.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Unit tests for alert.service.ts (#423)
//
// Covers: createAlert, listAlerts, deleteAlert.
// Uses Jest mocks for prisma — no database required.

import { createAlert, listAlerts, deleteAlert } from '../alert.service';
import { prisma } from '../../../utils/prisma.utils';

jest.mock('../../../utils/prisma.utils', () => ({
prisma: {
priceAlert: {
create: jest.fn(),
findMany: jest.fn(),
findFirst: jest.fn(),
delete: jest.fn(),
},
},
}));

const mockedPrisma = prisma as jest.Mocked<typeof prisma>;

const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';

const BASE_INPUT = {
creator_id: 'creator-1',
wallet_address: VALID_ADDRESS,
target_price: 100,
direction: 'above' as const,
callback_url: 'https://example.com/callback',
};

const DB_ALERT = {
id: 'alert-1',
creatorId: 'creator-1',
walletAddress: VALID_ADDRESS,
targetPrice: 100,
direction: 'above',
callbackUrl: 'https://example.com/callback',
isActive: true,
triggeredAt: null,
createdAt: new Date('2026-01-01T00:00:00Z'),
};

describe('createAlert', () => {
afterEach(() => jest.clearAllMocks());

it('calls prisma.priceAlert.create with correct data', async () => {
(mockedPrisma.priceAlert.create as jest.Mock).mockResolvedValue(DB_ALERT);

const result = await createAlert(BASE_INPUT);

expect(mockedPrisma.priceAlert.create).toHaveBeenCalledWith({
data: {
creatorId: 'creator-1',
walletAddress: VALID_ADDRESS,
targetPrice: 100,
direction: 'above',
callbackUrl: 'https://example.com/callback',
},
});
expect(result).toEqual(DB_ALERT);
});

it('creates a below-direction alert', async () => {
const input = { ...BASE_INPUT, direction: 'below' as const, target_price: 50 };
(mockedPrisma.priceAlert.create as jest.Mock).mockResolvedValue({
...DB_ALERT,
direction: 'below',
targetPrice: 50,
});

const result = await createAlert(input);
expect(result.direction).toBe('below');
});
});

describe('listAlerts', () => {
afterEach(() => jest.clearAllMocks());

it('returns active alerts for a wallet address', async () => {
(mockedPrisma.priceAlert.findMany as jest.Mock).mockResolvedValue([DB_ALERT]);

const result = await listAlerts(VALID_ADDRESS);

expect(mockedPrisma.priceAlert.findMany).toHaveBeenCalledWith({
where: { walletAddress: VALID_ADDRESS, isActive: true },
orderBy: { createdAt: 'desc' },
});
expect(result).toHaveLength(1);
expect(result[0].id).toBe('alert-1');
});

it('returns empty array when no alerts exist', async () => {
(mockedPrisma.priceAlert.findMany as jest.Mock).mockResolvedValue([]);

const result = await listAlerts(VALID_ADDRESS);
expect(result).toEqual([]);
});
});

describe('deleteAlert', () => {
afterEach(() => jest.clearAllMocks());

it('deletes the alert and returns its id when found', async () => {
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(DB_ALERT);
(mockedPrisma.priceAlert.delete as jest.Mock).mockResolvedValue(DB_ALERT);

const result = await deleteAlert('alert-1', VALID_ADDRESS);

expect(mockedPrisma.priceAlert.findFirst).toHaveBeenCalledWith({
where: { id: 'alert-1', walletAddress: VALID_ADDRESS },
});
expect(mockedPrisma.priceAlert.delete).toHaveBeenCalledWith({
where: { id: 'alert-1' },
});
expect(result).toEqual({ id: 'alert-1' });
});

it('returns null when the alert is not found', async () => {
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null);

const result = await deleteAlert('nonexistent', VALID_ADDRESS);

expect(result).toBeNull();
expect(mockedPrisma.priceAlert.delete).not.toHaveBeenCalled();
});

it('does not delete an alert belonging to a different wallet address', async () => {
(mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null);

const result = await deleteAlert('alert-1', 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB');
expect(result).toBeNull();
});
});
122 changes: 122 additions & 0 deletions src/modules/alerts/alert.controllers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { Request, Response, NextFunction } from 'express';
import {
CreateAlertSchema,
ListAlertsQuerySchema,
AlertParamsSchema,
DeleteAlertBodySchema,
} from './alert.schemas';
import { createAlert, listAlerts, deleteAlert } from './alert.service';
import {
sendSuccess,
sendValidationError,
sendNotFound,
} from '../../utils/api-response.utils';

/**
* POST /api/v1/alerts
* Register a new price alert.
*/
export async function httpCreateAlert(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const parsed = CreateAlertSchema.safeParse(req.body);
if (!parsed.success) {
sendValidationError(
res,
'Invalid alert input',
parsed.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({
field: issue.path.join('.'),
message: issue.message,
}))
);
return;
}

const alert = await createAlert(parsed.data);
sendSuccess(res, alert, 201);
} catch (error) {
next(error);
}
}

/**
* GET /api/v1/alerts?wallet_address=...
* List all active price alerts for a wallet address.
*/
export async function httpListAlerts(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const parsed = ListAlertsQuerySchema.safeParse(req.query);
if (!parsed.success) {
sendValidationError(
res,
'Invalid query parameters',
parsed.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({
field: issue.path.join('.'),
message: issue.message,
}))
);
return;
}

const alerts = await listAlerts(parsed.data.wallet_address);
sendSuccess(res, { items: alerts, total: alerts.length });
} catch (error) {
next(error);
}
}

/**
* DELETE /api/v1/alerts/:id
* Delete a price alert by id, scoped to the wallet address in the request body.
*/
export async function httpDeleteAlert(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
const parsedParams = AlertParamsSchema.safeParse(req.params);
if (!parsedParams.success) {
sendValidationError(
res,
'Invalid alert id',
parsedParams.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({
field: issue.path.join('.'),
message: issue.message,
}))
);
return;
}

const parsedBody = DeleteAlertBodySchema.safeParse(req.body);
if (!parsedBody.success) {
sendValidationError(
res,
'Invalid request body',
parsedBody.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({
field: issue.path.join('.'),
message: issue.message,
}))
);
return;
}

const result = await deleteAlert(parsedParams.data.id, parsedBody.data.wallet_address);

if (!result) {
sendNotFound(res, 'Alert');
return;
}

sendSuccess(res, result);
} catch (error) {
next(error);
}
}
24 changes: 24 additions & 0 deletions src/modules/alerts/alert.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Router } from 'express';
import { httpCreateAlert, httpListAlerts, httpDeleteAlert } from './alert.controllers';

const alertsRouter = Router();

/**
* POST /api/v1/alerts
* Register a new price alert for a creator key price threshold.
*/
alertsRouter.post('/', httpCreateAlert);

/**
* GET /api/v1/alerts?wallet_address=...
* List all active price alerts for the given Stellar wallet address.
*/
alertsRouter.get('/', httpListAlerts);

/**
* DELETE /api/v1/alerts/:id
* Delete a price alert by id (wallet_address required in body for authorization).
*/
alertsRouter.delete('/:id', httpDeleteAlert);

export default alertsRouter;
38 changes: 38 additions & 0 deletions src/modules/alerts/alert.schemas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { z } from 'zod';
import { isValidStellarAddress } from '../wallet/wallet.utils';

export const CreateAlertSchema = z.object({
creator_id: z.string().min(1, 'creator_id is required'),
wallet_address: z
.string()
.refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }),
target_price: z
.number({ invalid_type_error: 'target_price must be a number' })
.positive('target_price must be positive'),
direction: z.enum(['above', 'below'], {
errorMap: () => ({ message: "direction must be 'above' or 'below'" }),
}),
callback_url: z.string().url('callback_url must be a valid URL'),
});

export type CreateAlertInput = z.infer<typeof CreateAlertSchema>;

export const ListAlertsQuerySchema = z.object({
wallet_address: z
.string()
.refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }),
});

export type ListAlertsQueryType = z.infer<typeof ListAlertsQuerySchema>;

export const AlertParamsSchema = z.object({
id: z.string().min(1, 'Alert id is required'),
});

export const DeleteAlertBodySchema = z.object({
wallet_address: z
.string()
.refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }),
});

export type DeleteAlertBodyType = z.infer<typeof DeleteAlertBodySchema>;
47 changes: 47 additions & 0 deletions src/modules/alerts/alert.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { prisma } from '../../utils/prisma.utils';
import { CreateAlertInput } from './alert.schemas';

/**
* Creates a new price alert for a wallet address watching a creator's key price.
*/
export async function createAlert(input: CreateAlertInput) {
return await prisma.priceAlert.create({
data: {
creatorId: input.creator_id,
walletAddress: input.wallet_address,
targetPrice: input.target_price,
direction: input.direction,
callbackUrl: input.callback_url,
},
});
}

/**
* Lists all active price alerts for a given wallet address.
*/
export async function listAlerts(walletAddress: string) {
return await prisma.priceAlert.findMany({
where: { walletAddress, isActive: true },
orderBy: { createdAt: 'desc' },
});
}

/**
* Deletes a price alert by id, scoped to the wallet address for authorization.
* Returns the deleted record id or null if not found.
*/
export async function deleteAlert(
id: string,
walletAddress: string
): Promise<{ id: string } | null> {
const existing = await prisma.priceAlert.findFirst({
where: { id, walletAddress },
});

if (!existing) {
return null;
}

await prisma.priceAlert.delete({ where: { id } });
return { id };
}
Loading
Loading