diff --git a/src/modules/alerts/__tests__/alert.service.test.ts b/src/modules/alerts/__tests__/alert.service.test.ts index 77456f91..a5e64c9c 100644 --- a/src/modules/alerts/__tests__/alert.service.test.ts +++ b/src/modules/alerts/__tests__/alert.service.test.ts @@ -73,7 +73,7 @@ describe('createAlert', () => { direction: DB_ALERT.direction, target_price: DB_ALERT.targetPrice, registered_at: DB_ALERT.createdAt, - wallet_address: 'GAAA***AAAA', + wallet_address: 'GAAA…AAAA', }), 'Price alert registered' ); @@ -137,7 +137,7 @@ describe('deleteAlert', () => { const result = await deleteAlert('alert-1', VALID_ADDRESS); expect(mockedPrisma.priceAlert.findFirst).toHaveBeenCalledWith({ - where: { id: 'alert-1', walletAddress: VALID_ADDRESS }, + where: { id: 'alert-1' }, }); expect(mockedPrisma.priceAlert.delete).toHaveBeenCalledWith({ where: { id: 'alert-1' }, @@ -149,7 +149,7 @@ describe('deleteAlert', () => { alert_id: DB_ALERT.id, creator_id: DB_ALERT.creatorId, cancelled_at: expect.any(Date), - wallet_address: 'GAAA***AAAA', + wallet_address: 'GAAA…AAAA', }), 'Price alert cancelled' ); @@ -168,12 +168,14 @@ describe('deleteAlert', () => { }); 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(); + (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(DB_ALERT); + + await expect( + deleteAlert( + 'alert-1', + 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + ) + ).rejects.toThrow('You do not have permission to delete this alert'); + expect(mockedPrisma.priceAlert.delete).not.toHaveBeenCalled(); }); }); diff --git a/src/modules/alerts/__tests__/alert.test.ts b/src/modules/alerts/__tests__/alert.test.ts new file mode 100644 index 00000000..840c14f6 --- /dev/null +++ b/src/modules/alerts/__tests__/alert.test.ts @@ -0,0 +1,294 @@ +import express from 'express'; +import request from 'supertest'; +import alertsRouter from '../alert.router'; +import { prisma } from '../../../utils/prisma.utils'; +import { signWalletAccessToken } from '../../../utils/jwt.utils'; + +jest.mock('../../../utils/prisma.utils', () => ({ + prisma: { + priceAlert: { + create: jest.fn(), + findMany: jest.fn(), + findFirst: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + }, +})); + +jest.mock('../../../utils/logger.utils', () => ({ + logger: { + info: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, +})); + +const mockedPrisma = prisma as jest.Mocked; + +const WALLET_A = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const WALLET_B = 'GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB'; + +const app = express(); +app.use(express.json()); +app.use('/api/v1/alerts', alertsRouter); + +describe('Price Alerts Endpoints (#867)', () => { + let tokenA: string; + let tokenB: string; + + beforeAll(() => { + tokenA = signWalletAccessToken(WALLET_A); + tokenB = signWalletAccessToken(WALLET_B); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('POST /api/v1/alerts', () => { + it('returns 401 when no authorization header is sent', async () => { + const res = await request(app) + .post('/api/v1/alerts') + .send({ + keyId: 'creator-1', + targetPrice: 100, + direction: 'above', + }); + + expect(res.status).toBe(401); + expect(res.body.success).toBe(false); + expect(mockedPrisma.priceAlert.create).not.toHaveBeenCalled(); + }); + + it('creates an alert for the authenticated wallet', async () => { + const mockCreated = { + id: 'alert-1', + creatorId: 'creator-1', + walletAddress: WALLET_A, + targetPrice: 100, + direction: 'above', + callbackUrl: 'https://accesslayer.org/webhooks/alerts', + isActive: true, + triggeredAt: null, + createdAt: new Date(), + }; + (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null); + (mockedPrisma.priceAlert.create as jest.Mock).mockResolvedValue(mockCreated); + + const res = await request(app) + .post('/api/v1/alerts') + .set('Authorization', `Bearer ${tokenA}`) + .send({ + keyId: 'creator-1', + targetPrice: 100, + direction: 'above', + }); + + expect(res.status).toBe(201); + expect(res.body.success).toBe(true); + expect(res.body.data.id).toBe('alert-1'); + expect(mockedPrisma.priceAlert.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + creatorId: 'creator-1', + walletAddress: WALLET_A, + targetPrice: 100, + direction: 'above', + }), + }); + }); + + it('returns 400 when targetPrice is not positive', async () => { + const res = await request(app) + .post('/api/v1/alerts') + .set('Authorization', `Bearer ${tokenA}`) + .send({ + keyId: 'creator-1', + targetPrice: -10, + direction: 'above', + }); + + expect(res.status).toBe(400); + expect(mockedPrisma.priceAlert.create).not.toHaveBeenCalled(); + }); + + it('returns 400 when direction is invalid', async () => { + const res = await request(app) + .post('/api/v1/alerts') + .set('Authorization', `Bearer ${tokenA}`) + .send({ + keyId: 'creator-1', + targetPrice: 100, + direction: 'sideways', + }); + + expect(res.status).toBe(400); + expect(mockedPrisma.priceAlert.create).not.toHaveBeenCalled(); + }); + + it('returns 409 when identical active alert already exists', async () => { + (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue({ + id: 'existing-alert', + }); + + const res = await request(app) + .post('/api/v1/alerts') + .set('Authorization', `Bearer ${tokenA}`) + .send({ + keyId: 'creator-1', + targetPrice: 100, + direction: 'above', + }); + + expect(res.status).toBe(409); + expect(mockedPrisma.priceAlert.create).not.toHaveBeenCalled(); + }); + }); + + describe('GET /api/v1/alerts', () => { + it('returns 401 when unauthenticated', async () => { + const res = await request(app).get('/api/v1/alerts'); + expect(res.status).toBe(401); + }); + + it('returns active alerts for caller wallet', async () => { + const alerts = [ + { + id: 'alert-1', + creatorId: 'creator-1', + walletAddress: WALLET_A, + targetPrice: 100, + direction: 'above', + isActive: true, + }, + ]; + (mockedPrisma.priceAlert.findMany as jest.Mock).mockResolvedValue(alerts); + + const res = await request(app) + .get('/api/v1/alerts') + .set('Authorization', `Bearer ${tokenA}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data.items).toHaveLength(1); + expect(mockedPrisma.priceAlert.findMany).toHaveBeenCalledWith({ + where: { walletAddress: WALLET_A, isActive: true }, + orderBy: { createdAt: 'desc' }, + }); + }); + }); + + describe('PATCH /api/v1/alerts/:alertId/triggered', () => { + it('returns 401 when unauthenticated', async () => { + const res = await request(app).patch('/api/v1/alerts/alert-1/triggered'); + expect(res.status).toBe(401); + }); + + it('returns 404 when alert does not exist', async () => { + (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null); + + const res = await request(app) + .patch('/api/v1/alerts/nonexistent/triggered') + .set('Authorization', `Bearer ${tokenA}`); + + expect(res.status).toBe(404); + }); + + it('returns 403 when caller is not the owner of the alert', async () => { + (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue({ + id: 'alert-1', + walletAddress: WALLET_A, // Owned by WALLET_A + isActive: true, + }); + + // WALLET_B attempts to trigger WALLET_A's alert + const res = await request(app) + .patch('/api/v1/alerts/alert-1/triggered') + .set('Authorization', `Bearer ${tokenB}`); + + expect(res.status).toBe(403); + expect(mockedPrisma.priceAlert.update).not.toHaveBeenCalled(); + }); + + it('successfully marks alert as triggered when caller is owner', async () => { + (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue({ + id: 'alert-1', + walletAddress: WALLET_A, + isActive: true, + }); + (mockedPrisma.priceAlert.update as jest.Mock).mockResolvedValue({ + id: 'alert-1', + walletAddress: WALLET_A, + isActive: false, + triggeredAt: new Date(), + }); + + const res = await request(app) + .patch('/api/v1/alerts/alert-1/triggered') + .set('Authorization', `Bearer ${tokenA}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(mockedPrisma.priceAlert.update).toHaveBeenCalledWith({ + where: { id: 'alert-1' }, + data: expect.objectContaining({ + isActive: false, + triggeredAt: expect.any(Date), + }), + }); + }); + }); + + describe('DELETE /api/v1/alerts/:alertId', () => { + it('returns 401 when unauthenticated', async () => { + const res = await request(app).delete('/api/v1/alerts/alert-1'); + expect(res.status).toBe(401); + }); + + it('returns 404 when alert does not exist', async () => { + (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue(null); + + const res = await request(app) + .delete('/api/v1/alerts/nonexistent') + .set('Authorization', `Bearer ${tokenA}`); + + expect(res.status).toBe(404); + }); + + it('returns 403 when caller is not the owner of the alert', async () => { + (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue({ + id: 'alert-1', + walletAddress: WALLET_A, // Owned by WALLET_A + }); + + // WALLET_B attempts to delete WALLET_A's alert + const res = await request(app) + .delete('/api/v1/alerts/alert-1') + .set('Authorization', `Bearer ${tokenB}`); + + expect(res.status).toBe(403); + expect(mockedPrisma.priceAlert.delete).not.toHaveBeenCalled(); + }); + + it('successfully deletes alert when caller is owner', async () => { + (mockedPrisma.priceAlert.findFirst as jest.Mock).mockResolvedValue({ + id: 'alert-1', + walletAddress: WALLET_A, + }); + (mockedPrisma.priceAlert.delete as jest.Mock).mockResolvedValue({ + id: 'alert-1', + }); + + const res = await request(app) + .delete('/api/v1/alerts/alert-1') + .set('Authorization', `Bearer ${tokenA}`); + + expect(res.status).toBe(200); + expect(res.body.success).toBe(true); + expect(res.body.data).toEqual({ id: 'alert-1' }); + expect(mockedPrisma.priceAlert.delete).toHaveBeenCalledWith({ + where: { id: 'alert-1' }, + }); + }); + }); +}); diff --git a/src/modules/alerts/alert.controllers.ts b/src/modules/alerts/alert.controllers.ts index d6463774..3e561e93 100644 --- a/src/modules/alerts/alert.controllers.ts +++ b/src/modules/alerts/alert.controllers.ts @@ -1,20 +1,23 @@ import { Request, Response, NextFunction } from 'express'; +import { CreateAlertSchema } from './alert.schemas'; import { - CreateAlertSchema, - ListAlertsQuerySchema, - AlertParamsSchema, - DeleteAlertBodySchema, -} from './alert.schemas'; -import { createAlert, listAlerts, deleteAlert } from './alert.service'; + createAlert, + listAlerts, + deleteAlert, + triggerAlert, +} from './alert.service'; import { sendSuccess, sendValidationError, sendNotFound, + sendError, + ErrorCode, } from '../../utils/api-response.utils'; +import { AuthenticatedRequest } from '../../middlewares/jwt-auth.middleware'; /** * POST /api/v1/alerts - * Register a new price alert. + * Register a new price alert for the authenticated wallet. */ export async function httpCreateAlert( req: Request, @@ -22,7 +25,13 @@ export async function httpCreateAlert( next: NextFunction ): Promise { try { - const parsed = CreateAlertSchema.safeParse(req.body); + const authWallet = (req as AuthenticatedRequest).user?.wallet; + const payload = { + ...req.body, + wallet_address: authWallet || req.body?.wallet_address || req.body?.walletAddress, + }; + + const parsed = CreateAlertSchema.safeParse(payload); if (!parsed.success) { sendValidationError( res, @@ -37,16 +46,27 @@ export async function httpCreateAlert( return; } - const alert = await createAlert(parsed.data); + if (!parsed.data.wallet_address) { + sendValidationError(res, 'Invalid alert input', [ + { field: 'wallet_address', message: 'wallet_address is required' }, + ]); + return; + } + + const alert = await createAlert(parsed.data as any); sendSuccess(res, alert, 201); - } catch (error) { + } catch (error: any) { + if (error?.statusCode === 409 || error?.code === 'DUPLICATE_ALERT') { + sendError(res, 409, ErrorCode.CONFLICT, error.message); + return; + } next(error); } } /** * GET /api/v1/alerts?wallet_address=... - * List all active price alerts for a wallet address. + * List all active price alerts for the authenticated wallet address. */ export async function httpListAlerts( req: Request, @@ -54,22 +74,21 @@ export async function httpListAlerts( next: NextFunction ): Promise { 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, - }) - ) - ); + const authWallet = (req as AuthenticatedRequest).user?.wallet; + const queryWallet = req.query.wallet_address || req.query.walletAddress; + const targetWallet = authWallet || queryWallet; + + if (!targetWallet || typeof targetWallet !== 'string') { + sendValidationError(res, 'Invalid query parameters', [ + { + field: 'wallet_address', + message: 'wallet_address is required', + }, + ]); return; } - const alerts = await listAlerts(parsed.data.wallet_address); + const alerts = await listAlerts(targetWallet); sendSuccess(res, { items: alerts, total: alerts.length }); } catch (error) { next(error); @@ -77,49 +96,65 @@ export async function httpListAlerts( } /** - * DELETE /api/v1/alerts/:id - * Delete a price alert by id, scoped to the wallet address in the request body. + * PATCH /api/v1/alerts/:alertId/triggered + * Mark a price alert as triggered (owner only). */ -export async function httpDeleteAlert( +export async function httpTriggerAlert( req: Request, res: Response, next: NextFunction ): Promise { 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, - }) - ) - ); + const alertId = (req.params.alertId || req.params.id) as string; + if (!alertId) { + sendValidationError(res, 'Invalid alert id', [ + { field: 'alertId', message: 'Alert id is required' }, + ]); 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, - }) - ) - ); + const authWallet = (req as AuthenticatedRequest).user?.wallet; + const result = await triggerAlert(alertId, authWallet); + + if (!result) { + sendNotFound(res, 'Alert'); return; } - const result = await deleteAlert( - parsedParams.data.id, - parsedBody.data.wallet_address - ); + sendSuccess(res, result); + } catch (error: any) { + if (error?.statusCode === 403 || error?.code === 'FORBIDDEN') { + sendError(res, 403, ErrorCode.FORBIDDEN, error.message); + return; + } + next(error); + } +} + +/** + * DELETE /api/v1/alerts/:id + * Delete a price alert by id (owner only). + */ +export async function httpDeleteAlert( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const alertId = (req.params.alertId || req.params.id) as string; + if (!alertId) { + sendValidationError(res, 'Invalid alert id', [ + { field: 'id', message: 'Alert id is required' }, + ]); + return; + } + + const authWallet = + (req as AuthenticatedRequest).user?.wallet || + req.body?.wallet_address || + req.body?.walletAddress; + + const result = await deleteAlert(alertId, authWallet); if (!result) { sendNotFound(res, 'Alert'); @@ -127,7 +162,11 @@ export async function httpDeleteAlert( } sendSuccess(res, result); - } catch (error) { + } catch (error: any) { + if (error?.statusCode === 403 || error?.code === 'FORBIDDEN') { + sendError(res, 403, ErrorCode.FORBIDDEN, error.message); + return; + } next(error); } } diff --git a/src/modules/alerts/alert.router.ts b/src/modules/alerts/alert.router.ts index b99ef3c6..bd05a3ed 100644 --- a/src/modules/alerts/alert.router.ts +++ b/src/modules/alerts/alert.router.ts @@ -1,28 +1,41 @@ import { Router } from 'express'; +import { requireJwtAuth } from '../../middlewares/jwt-auth.middleware'; import { httpCreateAlert, httpListAlerts, + httpTriggerAlert, httpDeleteAlert, } from './alert.controllers'; const alertsRouter = Router(); +// Protect all alert routes with JWT authentication +alertsRouter.use(requireJwtAuth); + /** * POST /api/v1/alerts - * Register a new price alert for a creator key price threshold. + * Register a new price alert for the authenticated wallet. */ alertsRouter.post('/', httpCreateAlert); /** - * GET /api/v1/alerts?wallet_address=... - * List all active price alerts for the given Stellar wallet address. + * GET /api/v1/alerts + * List all active price alerts for the authenticated wallet. */ alertsRouter.get('/', httpListAlerts); /** - * DELETE /api/v1/alerts/:id - * Delete a price alert by id (wallet_address required in body for authorization). + * PATCH /api/v1/alerts/:alertId/triggered + * Mark a price alert as triggered (owner only). + */ +alertsRouter.patch('/:alertId/triggered', httpTriggerAlert); +alertsRouter.patch('/:id/triggered', httpTriggerAlert); + +/** + * DELETE /api/v1/alerts/:alertId + * Delete a price alert by id (owner only). */ +alertsRouter.delete('/:alertId', httpDeleteAlert); alertsRouter.delete('/:id', httpDeleteAlert); export default alertsRouter; diff --git a/src/modules/alerts/alert.schemas.ts b/src/modules/alerts/alert.schemas.ts index 35120998..3c3d3bdf 100644 --- a/src/modules/alerts/alert.schemas.ts +++ b/src/modules/alerts/alert.schemas.ts @@ -1,44 +1,110 @@ 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', +export const CreateAlertSchema = z + .object({ + 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(), + wallet_address: z + .string() + .refine(isValidStellarAddress, { + message: 'Invalid Stellar wallet address', + }) + .optional(), + walletAddress: z + .string() + .refine(isValidStellarAddress, { + message: 'Invalid Stellar wallet address', + }) + .optional(), + target_price: z + .number({ invalid_type_error: 'target_price must be a number' }) + .positive('target_price must be positive') + .optional(), + targetPrice: z + .number({ invalid_type_error: 'targetPrice must be a number' }) + .positive('targetPrice must be positive') + .optional(), + direction: z.enum(['above', 'below'], { + errorMap: () => ({ message: "direction must be 'above' or 'below'" }), }), - 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'), -}); + callback_url: z.string().url('callback_url must be a valid URL').optional(), + callbackUrl: z.string().url('callbackUrl must be a valid URL').optional(), + }) + .refine( + (data) => !!(data.keyId || data.creator_id || data.creatorId), + { + message: 'keyId is required', + path: ['keyId'], + } + ) + .refine( + (data) => data.targetPrice !== undefined || data.target_price !== undefined, + { + message: 'targetPrice must be a positive number', + path: ['targetPrice'], + } + ) + .transform((data) => ({ + creator_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, + callback_url: + data.callbackUrl || + data.callback_url || + 'https://accesslayer.org/webhooks/alerts', + })); -export type CreateAlertInput = z.infer; +export type CreateAlertInput = { + creator_id: string; + wallet_address: string; + target_price: number; + direction: 'above' | 'below'; + callback_url: string; +}; export const ListAlertsQuerySchema = z.object({ wallet_address: z .string() .refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address', - }), + }) + .optional(), + walletAddress: z + .string() + .refine(isValidStellarAddress, { + message: 'Invalid Stellar wallet address', + }) + .optional(), }); export type ListAlertsQueryType = z.infer; -export const AlertParamsSchema = z.object({ - id: z.string().min(1, 'Alert id is required'), -}); +export const AlertParamsSchema = z + .object({ + id: z.string().optional(), + alertId: z.string().optional(), + }) + .refine((data) => !!(data.id || data.alertId), { + message: 'Alert id is required', + path: ['id'], + }); export const DeleteAlertBodySchema = z.object({ wallet_address: z .string() .refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address', - }), + }) + .optional(), + walletAddress: z + .string() + .refine(isValidStellarAddress, { + message: 'Invalid Stellar wallet address', + }) + .optional(), }); export type DeleteAlertBodyType = z.infer; diff --git a/src/modules/alerts/alert.service.ts b/src/modules/alerts/alert.service.ts index bc5043f9..f9750714 100644 --- a/src/modules/alerts/alert.service.ts +++ b/src/modules/alerts/alert.service.ts @@ -74,19 +74,29 @@ export async function listAlerts(walletAddress: string) { /** * Deletes a price alert by id, scoped to the wallet address for authorization. * Returns the deleted record id or null if not found. + * Throws 403 if the alert belongs to a different wallet. */ export async function deleteAlert( id: string, - walletAddress: string + walletAddress?: string ): Promise<{ id: string } | null> { const existing = await prisma.priceAlert.findFirst({ - where: { id, walletAddress }, + where: { id }, }); if (!existing) { return null; } + if (walletAddress && existing.walletAddress !== walletAddress) { + const error = new Error( + 'You do not have permission to delete this alert' + ) as any; + error.statusCode = 403; + error.code = 'FORBIDDEN'; + throw error; + } + await prisma.priceAlert.delete({ where: { id } }); logger.info( @@ -102,6 +112,54 @@ export async function deleteAlert( return { id }; } +/** + * Manually marks a price alert as triggered (inactive, with triggeredAt timestamp). + * Scoped to the wallet address for authorization. + * Returns the updated record or null if not found. + * Throws 403 if the alert belongs to a different wallet. + */ +export async function triggerAlert( + id: string, + walletAddress?: string +) { + const existing = await prisma.priceAlert.findFirst({ + where: { id }, + }); + + if (!existing) { + return null; + } + + if (walletAddress && existing.walletAddress !== walletAddress) { + const error = new Error( + 'You do not have permission to modify this alert' + ) as any; + error.statusCode = 403; + error.code = 'FORBIDDEN'; + throw error; + } + + const updated = await prisma.priceAlert.update({ + where: { id }, + data: { + isActive: false, + triggeredAt: new Date(), + }, + }); + + logger.info( + { + alert_id: updated.id, + creator_id: updated.creatorId, + triggered_at: updated.triggeredAt, + wallet_address: truncateWallet(updated.walletAddress), + }, + 'Price alert triggered' + ); + + return updated; +} + function toNumber(value: number | string | { toString(): string }): number { return typeof value === 'number' ? value : Number(value.toString()); }