diff --git a/prisma/schema/alert.prisma b/prisma/schema/alert.prisma new file mode 100644 index 0000000..e6ba7c2 --- /dev/null +++ b/prisma/schema/alert.prisma @@ -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]) +} diff --git a/src/modules/alerts/__tests__/alert.service.test.ts b/src/modules/alerts/__tests__/alert.service.test.ts new file mode 100644 index 0000000..8bc71a8 --- /dev/null +++ b/src/modules/alerts/__tests__/alert.service.test.ts @@ -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; + +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(); + }); +}); diff --git a/src/modules/alerts/alert.controllers.ts b/src/modules/alerts/alert.controllers.ts new file mode 100644 index 0000000..91c559d --- /dev/null +++ b/src/modules/alerts/alert.controllers.ts @@ -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 { + 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 { + 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 { + 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); + } +} diff --git a/src/modules/alerts/alert.router.ts b/src/modules/alerts/alert.router.ts new file mode 100644 index 0000000..90aaf75 --- /dev/null +++ b/src/modules/alerts/alert.router.ts @@ -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; diff --git a/src/modules/alerts/alert.schemas.ts b/src/modules/alerts/alert.schemas.ts new file mode 100644 index 0000000..0f7f2f1 --- /dev/null +++ b/src/modules/alerts/alert.schemas.ts @@ -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; + +export const ListAlertsQuerySchema = z.object({ + wallet_address: z + .string() + .refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }), +}); + +export type ListAlertsQueryType = z.infer; + +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; diff --git a/src/modules/alerts/alert.service.ts b/src/modules/alerts/alert.service.ts new file mode 100644 index 0000000..97f64b1 --- /dev/null +++ b/src/modules/alerts/alert.service.ts @@ -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 }; +} diff --git a/src/modules/index.ts b/src/modules/index.ts index 76bad1e..fa5cbc9 100644 --- a/src/modules/index.ts +++ b/src/modules/index.ts @@ -11,6 +11,7 @@ import activityRouter from './activity/activity.routes'; import ownershipRouter from './ownership/ownership.routes'; import webhookRouter from './webhooks/webhook.router'; import walletsRouter from './wallets/wallets.routes'; +import alertsRouter from './alerts/alert.router'; import { BASE as CREATORS_BASE } from '../constants/creator.constants'; const router = Router(); @@ -27,5 +28,6 @@ router.use('/activity', activityRouter); router.use('/ownership', ownershipRouter); router.use(CREATORS_BASE, webhookRouter); router.use('/wallets', walletsRouter); +router.use('/alerts', alertsRouter); export default router; diff --git a/src/modules/wallet/__tests__/wallet.utils.test.ts b/src/modules/wallet/__tests__/wallet.utils.test.ts new file mode 100644 index 0000000..900e7f1 --- /dev/null +++ b/src/modules/wallet/__tests__/wallet.utils.test.ts @@ -0,0 +1,81 @@ +// Unit tests for isValidStellarAddress and StellarAddressSchema (#447) + +import { isValidStellarAddress, StellarAddressSchema } from '../wallet.utils'; + +// 56-character valid Stellar G address (all uppercase base32 chars A-Z, 2-7) +const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; + +describe('isValidStellarAddress', () => { + it('returns true for a valid Stellar G... address', () => { + expect(isValidStellarAddress(VALID_ADDRESS)).toBe(true); + }); + + it('returns true for an address using digits 2-7', () => { + // Replace some chars with valid Base32 digits + const addr = 'G' + '2'.repeat(55); + expect(isValidStellarAddress(addr)).toBe(true); + }); + + it('returns false when the address does not start with G', () => { + const addr = 'A' + 'A'.repeat(55); + expect(isValidStellarAddress(addr)).toBe(false); + }); + + it('returns false when the address is too short (55 chars)', () => { + const addr = 'G' + 'A'.repeat(54); + expect(isValidStellarAddress(addr)).toBe(false); + }); + + it('returns false when the address is too long (57 chars)', () => { + const addr = 'G' + 'A'.repeat(56); + expect(isValidStellarAddress(addr)).toBe(false); + }); + + it('returns false when the address contains invalid characters (lowercase)', () => { + const addr = 'G' + 'a'.repeat(55); + expect(isValidStellarAddress(addr)).toBe(false); + }); + + it('returns false when the address contains invalid digits (0, 1, 8, 9)', () => { + const addr = 'G' + '0'.repeat(55); + expect(isValidStellarAddress(addr)).toBe(false); + }); + + it('returns false for an empty string', () => { + expect(isValidStellarAddress('')).toBe(false); + }); + + it('returns false for a random non-address string', () => { + expect(isValidStellarAddress('not-a-stellar-address')).toBe(false); + }); +}); + +describe('StellarAddressSchema', () => { + it('passes for a valid Stellar address', () => { + const result = StellarAddressSchema.safeParse(VALID_ADDRESS); + expect(result.success).toBe(true); + }); + + it('fails with the correct message for a wrong first character', () => { + const result = StellarAddressSchema.safeParse('A' + 'A'.repeat(55)); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues[0].message).toBe('Invalid Stellar wallet address'); + } + }); + + it('fails for wrong length', () => { + const result = StellarAddressSchema.safeParse('G' + 'A'.repeat(54)); + expect(result.success).toBe(false); + }); + + it('fails for invalid characters', () => { + const result = StellarAddressSchema.safeParse('G' + '!'.repeat(55)); + expect(result.success).toBe(false); + }); + + it('fails for an empty string', () => { + const result = StellarAddressSchema.safeParse(''); + expect(result.success).toBe(false); + }); +}); diff --git a/src/modules/wallet/wallet.utils.ts b/src/modules/wallet/wallet.utils.ts index f2710f6..64ad883 100644 --- a/src/modules/wallet/wallet.utils.ts +++ b/src/modules/wallet/wallet.utils.ts @@ -1,7 +1,24 @@ +import { z } from 'zod'; import { prisma } from '../../utils/prisma.utils'; import { MapUserToWalletType } from './wallet.schemas'; import { logger } from '../../utils/logger.utils'; +/** + * Validates a Stellar Ed25519 public key address. + * A valid address starts with 'G', is exactly 56 characters, and uses + * the Base32 character set (A-Z, 2-7). + */ +export function isValidStellarAddress(address: string): boolean { + return typeof address === 'string' && /^G[A-Z2-7]{55}$/.test(address); +} + +/** + * Zod schema that validates a Stellar wallet address using isValidStellarAddress. + */ +export const StellarAddressSchema = z + .string() + .refine(isValidStellarAddress, { message: 'Invalid Stellar wallet address' }); + /** * Service boundary for Stellar wallet identity mapping. * Handles the association between application users and their Stellar public addresses. diff --git a/src/modules/wallets/__tests__/wallet-holdings.integration.test.ts b/src/modules/wallets/__tests__/wallet-holdings.integration.test.ts new file mode 100644 index 0000000..3159b9f --- /dev/null +++ b/src/modules/wallets/__tests__/wallet-holdings.integration.test.ts @@ -0,0 +1,134 @@ +// Integration test: wallet holdings endpoint (#421) +// +// Covers: valid address returns holdings, invalid address returns 400, +// empty holdings, service error forwarded to next(). +// Uses Jest mocks — no database required. + +import { httpGetWalletHoldings } from '../wallet-holdings.controllers'; +import * as walletHoldingsService from '../wallet-holdings.service'; +import { HoldingEntry } from '../wallet-holdings.schemas'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const VALID_ADDRESS = 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; +const MALFORMED_ADDRESS = 'not-a-stellar-address'; + +function makeReq(params: Record = {}): any { + return { params }; +} + +function makeRes(): any { + const res: any = {}; + res.status = jest.fn().mockReturnValue(res); + res.setHeader = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; +} + +function makeNext(): jest.Mock { + return jest.fn(); +} + +function makeHolding(overrides: Partial = {}): HoldingEntry { + return { + creator_id: 'creator-1', + creator_handle: 'alice', + key_count: '5', + current_price: '100', + total_value: null, + ...overrides, + }; +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('GET /wallets/:address/holdings', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns 200 with items and total for a wallet with holdings', async () => { + const holdings: HoldingEntry[] = [ + makeHolding({ creator_id: 'creator-1', creator_handle: 'alice', key_count: '5' }), + makeHolding({ creator_id: 'creator-2', creator_handle: 'bob', key_count: '3' }), + ]; + jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([holdings, 2]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(true); + expect(body.data.items).toHaveLength(2); + expect(body.data.total).toBe(2); + }); + + it('each holding includes required fields', async () => { + const holding = makeHolding({ + creator_id: 'creator-1', + creator_handle: 'alice', + key_count: '10', + current_price: '200', + total_value: null, + }); + jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([[holding], 1]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + const item = res.json.mock.calls[0][0].data.items[0]; + expect(item).toMatchObject({ + creator_id: 'creator-1', + creator_handle: 'alice', + key_count: '10', + current_price: '200', + }); + }); + + it('returns 200 with empty items for a wallet with no holdings', async () => { + jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockResolvedValue([[], 0]); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(200); + const body = res.json.mock.calls[0][0]; + expect(body.data.items).toEqual([]); + expect(body.data.total).toBe(0); + }); + + it('returns 400 for a malformed Stellar address', async () => { + const req = makeReq({ address: MALFORMED_ADDRESS }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + const body = res.json.mock.calls[0][0]; + expect(body.success).toBe(false); + expect(body.error.code).toBe('VALIDATION_ERROR'); + }); + + it('returns 400 for an address starting with wrong character', async () => { + const req = makeReq({ address: 'A' + 'A'.repeat(55) }); + const res = makeRes(); + await httpGetWalletHoldings(req, res, makeNext()); + + expect(res.status).toHaveBeenCalledWith(400); + }); + + it('forwards service errors to next()', async () => { + const err = new Error('db down'); + jest.spyOn(walletHoldingsService, 'fetchWalletHoldings').mockRejectedValue(err); + + const req = makeReq({ address: VALID_ADDRESS }); + const res = makeRes(); + const next = makeNext(); + await httpGetWalletHoldings(req, res, next); + + expect(next).toHaveBeenCalledWith(err); + }); +}); diff --git a/src/modules/wallets/wallet-holdings.controllers.ts b/src/modules/wallets/wallet-holdings.controllers.ts new file mode 100644 index 0000000..4e999aa --- /dev/null +++ b/src/modules/wallets/wallet-holdings.controllers.ts @@ -0,0 +1,31 @@ +import { Request, Response, NextFunction } from 'express'; +import { WalletHoldingsParamsSchema } from './wallet-holdings.schemas'; +import { fetchWalletHoldings } from './wallet-holdings.service'; +import { sendSuccess, sendValidationError } from '../../utils/api-response.utils'; + +export async function httpGetWalletHoldings( + req: Request, + res: Response, + next: NextFunction +): Promise { + try { + const parsedParams = WalletHoldingsParamsSchema.safeParse(req.params); + if (!parsedParams.success) { + sendValidationError( + res, + 'Invalid wallet address', + parsedParams.error.issues.map((issue: { path: (string | number)[]; message: string }) => ({ + field: 'address', + message: issue.message, + })) + ); + return; + } + + const [items, total] = await fetchWalletHoldings(parsedParams.data.address); + + sendSuccess(res, { items, total }); + } catch (error) { + next(error); + } +} diff --git a/src/modules/wallets/wallet-holdings.schemas.ts b/src/modules/wallets/wallet-holdings.schemas.ts new file mode 100644 index 0000000..9524d7e --- /dev/null +++ b/src/modules/wallets/wallet-holdings.schemas.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { StellarAddressSchema } from '../wallet/wallet.schemas'; + +export const WalletHoldingsParamsSchema = z.object({ + address: StellarAddressSchema, +}); + +export type WalletHoldingsParamsType = z.infer; + +export const HoldingEntrySchema = z.object({ + creator_id: z.string(), + creator_handle: z.string().nullable(), + key_count: z.any(), + current_price: z.any().nullable(), + total_value: z.any().nullable(), +}); + +export type HoldingEntry = z.infer; diff --git a/src/modules/wallets/wallet-holdings.service.ts b/src/modules/wallets/wallet-holdings.service.ts new file mode 100644 index 0000000..f91f121 --- /dev/null +++ b/src/modules/wallets/wallet-holdings.service.ts @@ -0,0 +1,80 @@ +import { prisma } from '../../utils/prisma.utils'; +import { isValidStellarAddress } from '../wallet/wallet.utils'; +import { HoldingEntry } from './wallet-holdings.schemas'; + +/** + * Fetches all creator key holdings for a given Stellar wallet address. + * Returns a tuple of [items, total] so the controller has the count without + * a second query. + * + * Each entry includes: + * - creator_id, creator_handle + * - key_count (the raw Decimal balance from KeyOwnership) + * - current_price (the price_at_trade from the most recent trade involving this creator, if any) + * - total_value (null — not calculated server-side; consumers derive it from key_count * current_price) + */ +export async function fetchWalletHoldings( + address: string +): Promise<[HoldingEntry[], number]> { + if (!isValidStellarAddress(address)) { + const err = Object.assign( + new Error('Invalid Stellar wallet address'), + { statusCode: 400, code: 'VALIDATION_ERROR' } + ); + throw err; + } + + const rows = await prisma.keyOwnership.findMany({ + where: { ownerAddress: address }, + orderBy: { createdAt: 'desc' }, + }); + + const total = rows.length; + + if (total === 0) { + return [[], 0]; + } + + const creatorIds = [...new Set(rows.map((r: { creatorId: string }) => r.creatorId))]; + + // Resolve creator handles in one batched query + const creatorProfiles = await prisma.creatorProfile.findMany({ + where: { id: { in: creatorIds } }, + select: { id: true, handle: true }, + }); + const handleMap = new Map( + creatorProfiles.map((c: { id: string; handle: string }) => [c.id, c.handle]) + ); + + // Resolve latest price per creator from Activity in one query + const recentActivities = await prisma.activity.findMany({ + where: { + creatorId: { in: creatorIds }, + type: { in: ['KEY_BOUGHT', 'KEY_SOLD'] }, + }, + orderBy: { createdAt: 'desc' }, + select: { creatorId: true, payload: true }, + }); + + // Build a map of creatorId → most recent price_at_trade + const priceMap = new Map(); + for (const act of recentActivities) { + if (!priceMap.has(act.creatorId as string)) { + const payload = (act.payload ?? {}) as Record; + priceMap.set(act.creatorId as string, payload.price_at_trade ?? null); + } + } + + const items: HoldingEntry[] = rows.map((row: { creatorId: string; balance: unknown }) => { + const currentPrice = priceMap.get(row.creatorId) ?? null; + return { + creator_id: row.creatorId, + creator_handle: handleMap.get(row.creatorId) ?? null, + key_count: row.balance, + current_price: currentPrice, + total_value: null, + }; + }); + + return [items, total]; +} diff --git a/src/modules/wallets/wallets.routes.ts b/src/modules/wallets/wallets.routes.ts index 69db5c3..c663075 100644 --- a/src/modules/wallets/wallets.routes.ts +++ b/src/modules/wallets/wallets.routes.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import { httpGetWalletActivity } from './wallet-activity.controllers'; +import { httpGetWalletHoldings } from './wallet-holdings.controllers'; import { cacheControl } from '../../middlewares/cache-control.middleware'; import { ACTIVITY_FEED_CACHE_PRESET } from '../../constants/activity-feed-cache.constants'; @@ -18,4 +19,12 @@ walletsRouter.get( httpGetWalletActivity ); +/** + * GET /api/v1/wallets/:address/holdings + * + * Returns all creator key holdings for a given Stellar wallet address, + * including creator handle, key count, and latest known price. + */ +walletsRouter.get('/:address/holdings', httpGetWalletHoldings); + export default walletsRouter;