From ea323154d5c5c53f28d43e726ff0af13698ad5e5 Mon Sep 17 00:00:00 2001 From: Anioke Sebastian Date: Mon, 2 Mar 2026 16:39:14 +0100 Subject: [PATCH 1/2] feat(api): build portfolio, transaction, agent, deposit and withdraw APIs with whatsapp formatters (#6) --- jest.config.js | 10 +- package-lock.json | 85 +++++++------- package.json | 7 +- src/index.ts | 44 ++++---- src/middleware/auth.ts | 55 ++++++++++ src/routes/deposit.ts | 79 +++++++++++++ src/routes/portfolio.ts | 169 ++++++++++++++++++++++++++++ src/routes/protocols.ts | 53 +++++++++ src/routes/transactions.ts | 96 ++++++++++++++++ src/routes/withdraw.ts | 68 ++++++++++++ src/types/express.d.ts | 22 ++-- src/whatsapp/formatters.ts | 140 +++++++++++++++++++++++ tests/integration/api/api.test.ts | 177 ++++++++++++++++++++++++++++++ tests/setupEnv.ts | 8 ++ tsconfig.test.json | 7 ++ 15 files changed, 944 insertions(+), 76 deletions(-) create mode 100644 src/middleware/auth.ts create mode 100644 src/routes/deposit.ts create mode 100644 src/routes/portfolio.ts create mode 100644 src/routes/protocols.ts create mode 100644 src/routes/transactions.ts create mode 100644 src/routes/withdraw.ts create mode 100644 src/whatsapp/formatters.ts create mode 100644 tests/integration/api/api.test.ts create mode 100644 tests/setupEnv.ts create mode 100644 tsconfig.test.json diff --git a/jest.config.js b/jest.config.js index 885dd79..528d84c 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,12 +1,16 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - roots: ['/src'], - testMatch: ['**/__tests__/**/*.test.ts'], + roots: ['/src', '/tests'], + testMatch: ['**/?(*.)+(test).ts'], + setupFiles: ['/tests/setupEnv.ts'], + transform: { + '^.+\\.ts$': ['ts-jest', { tsconfig: '/tsconfig.test.json' }], + }, moduleFileExtensions: ['ts', 'js'], collectCoverageFrom: [ 'src/**/*.ts', '!src/**/*.test.ts', '!src/**/__tests__/**', ], -}; +} diff --git a/package-lock.json b/package-lock.json index ca60533..d6d670d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,8 @@ "jsonwebtoken": "^9.0.3", "node-cron": "^4.2.1", "twilio": "^4.11.0", - "winston": "^3.19.0" + "winston": "^3.19.0", + "zod": "^4.3.6" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", @@ -31,12 +32,12 @@ "@types/jsonwebtoken": "^9.0.10", "@types/node": "^25.3.0", "@types/node-cron": "^3.0.11", - "@types/supertest": "^6.0.2", + "@types/supertest": "^7.2.0", "@types/twilio": "^3.19.3", "jest": "^30.2.0", "nodemon": "^3.1.14", "prisma": "^5.22.0", - "supertest": "^6.3.3", + "supertest": "^7.2.2", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.9.3" @@ -1253,14 +1254,14 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1274,14 +1275,14 @@ "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/fetch-engine": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0", @@ -1293,7 +1294,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "5.22.0" @@ -1679,9 +1680,9 @@ } }, "node_modules/@types/supertest": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", - "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", "dev": true, "license": "MIT", "dependencies": { @@ -3605,16 +3606,18 @@ } }, "node_modules/formidable": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", - "integrity": "sha512-Oz5Hwvwak/DCaXVVUtPn4oLMLLy1CdclLKO1LFgU7XzDpVMUU5UjlSLpGMocyQNNk8F6IJW9M/YdooSn2MRI+Q==", + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", "dev": true, "license": "MIT", "dependencies": { "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", - "once": "^1.4.0", - "qs": "^6.11.0" + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" }, "funding": { "url": "https://ko-fi.com/tunnckoCore/commissions" @@ -3649,7 +3652,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5319,9 +5321,9 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz", + "integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { @@ -5779,7 +5781,7 @@ "version": "5.22.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -6497,41 +6499,39 @@ } }, "node_modules/superagent": { - "version": "8.1.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", - "integrity": "sha512-6WTxW1EB6yCxV5VFOIPQruWGHqc3yI7hEmZK6h+pyk69Lk/Ut7rLUY6W/ONF2MjBuGjvmMiIpsrVJ2vjrHlslA==", - "deprecated": "Please upgrade to superagent v10.2.2+, see release notes at https://github.com/forwardemail/superagent/releases/tag/v10.2.2 - maintenance is supported by Forward Email @ https://forwardemail.net", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", "dev": true, "license": "MIT", "dependencies": { - "component-emitter": "^1.3.0", + "component-emitter": "^1.3.1", "cookiejar": "^2.1.4", - "debug": "^4.3.4", + "debug": "^4.3.7", "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^2.1.2", + "form-data": "^4.0.5", + "formidable": "^3.5.4", "methods": "^1.1.2", "mime": "2.6.0", - "qs": "^6.11.0", - "semver": "^7.3.8" + "qs": "^6.14.1" }, "engines": { - "node": ">=6.4.0 <13 || >=14" + "node": ">=14.18.0" } }, "node_modules/supertest": { - "version": "6.3.4", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-6.3.4.tgz", - "integrity": "sha512-erY3HFDG0dPnhw4U+udPfrzXa4xhSG+n4rxfRuZWCUvjFWwKl+OxWf/7zk50s84/fAAs7vf5QAb9uRa0cCykxw==", - "deprecated": "Please upgrade to supertest v7.1.3+, see release notes at https://github.com/forwardemail/supertest/releases/tag/v7.1.3 - maintenance is supported by Forward Email @ https://forwardemail.net", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", "dev": true, "license": "MIT", "dependencies": { + "cookie-signature": "^1.2.2", "methods": "^1.1.2", - "superagent": "^8.1.2" + "superagent": "^10.3.0" }, "engines": { - "node": ">=6.4.0" + "node": ">=14.18.0" } }, "node_modules/supports-color": { @@ -7407,6 +7407,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 4546435..83a9297 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,8 @@ "jsonwebtoken": "^9.0.3", "node-cron": "^4.2.1", "twilio": "^4.11.0", - "winston": "^3.19.0" + "winston": "^3.19.0", + "zod": "^4.3.6" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", @@ -49,11 +50,11 @@ "@types/node": "^25.3.0", "@types/node-cron": "^3.0.11", "@types/twilio": "^3.19.3", + "@types/supertest": "^7.2.0", "jest": "^30.2.0", "nodemon": "^3.1.14", "prisma": "^5.22.0", - "@types/supertest": "^6.0.2", - "supertest": "^6.3.3", + "supertest": "^7.2.2", "ts-jest": "^29.4.6", "ts-node": "^10.9.2", "typescript": "^5.9.3" diff --git a/src/index.ts b/src/index.ts index 1084f12..06f995f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,6 @@ import { config } from './config/env' import { errorHandler } from './middleware/errorHandler' import { requestLogger } from './middleware/logger' import { rateLimiter } from './middleware/rateLimiter' -import { AuthMiddleware } from './middleware/authenticate' import { logger } from './utils/logger' import { startAgentLoop } from './agent/loop' import { connectDb } from './db' @@ -14,6 +13,11 @@ import healthRouter from './routes/health' import agentRouter from './routes/agent' import authRouter from './routes/auth' import whatsappRouter from './routes/whatsapp' +import portfolioRouter from './routes/portfolio' +import transactionsRouter from './routes/transactions' +import protocolsRouter from './routes/protocols' +import depositRouter from './routes/deposit' +import withdrawRouter from './routes/withdraw' const app = express() @@ -32,49 +36,39 @@ app.use('/health', healthRouter) app.use('/api/agent', agentRouter) app.use('/api/auth', authRouter) app.use('/api/whatsapp', whatsappRouter) - -// Protected routes (require valid JWT) -// All routes mounted below this line are automatically protected. -app.use('/api/portfolio', AuthMiddleware.validateJwt) -app.use('/api/transactions', AuthMiddleware.validateJwt) -app.use('/api/deposit', AuthMiddleware.validateJwt) -app.use('/api/withdraw', AuthMiddleware.validateJwt) - -// TODO: mount actual portfolio / transaction / deposit / withdraw routers here -// e.g. app.use('/api/portfolio', portfolioRouter) +app.use('/api/portfolio', portfolioRouter) +app.use('/api/transactions', transactionsRouter) +app.use('/api/protocols', protocolsRouter) +app.use('/api/deposit', depositRouter) +app.use('/api/withdraw', withdrawRouter) // Global error handler โ€” must always be last app.use(errorHandler) -// Start server async function main() { - // Database connectivity check await connectDb() - - // Background jobs scheduleSessionCleanup() - // Start HTTP server - const server = app.listen(config.port, async () => { + app.listen(config.port, async () => { logger.info(`NeuroWealth backend running on port ${config.port}`) logger.info(`Environment: ${config.nodeEnv}`) logger.info(`Network: ${config.stellar.network}`) - - // Start autonomous agent loop + try { await startAgentLoop() } catch (error) { logger.error('Failed to start agent loop', { error: error instanceof Error ? error.message : 'Unknown error' }) - // Continue server operation even if agent fails to start } }) } -main().catch((error) => { - logger.error('[Startup] Unexpected error:', error) - process.exit(1) -}) +if (require.main === module) { + main().catch((error) => { + logger.error('[Startup] Unexpected error:', error) + process.exit(1) + }) +} -export default app \ No newline at end of file +export default app diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..e8f96da --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,55 @@ +import { NextFunction, Request, Response } from 'express' +import db from '../db' + +function getBearerToken(req: Request): string | null { + const header = req.headers.authorization + if (!header) return null + const [scheme, token] = header.split(' ') + if (scheme !== 'Bearer' || !token) return null + return token +} + +export async function requireAuth( + req: Request, + res: Response, + next: NextFunction, +) { + const token = getBearerToken(req) + if (!token) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + const session = await db.session.findUnique({ + where: { token }, + include: { user: true }, + }) + + if (!session || session.expiresAt < new Date() || !session.user.isActive) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + req.auth = { + userId: session.userId, + sessionId: session.id, + walletAddress: session.walletAddress, + network: session.network, + } + + return next() +} + +export function enforceUserAccess( + req: Request, + res: Response, + next: NextFunction, +) { + const requestedUserId = (req.params.userId || req.body?.userId) as + | string + | undefined + + if (!req.auth || (requestedUserId && req.auth.userId !== requestedUserId)) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + return next() +} diff --git a/src/routes/deposit.ts b/src/routes/deposit.ts new file mode 100644 index 0000000..41c10b6 --- /dev/null +++ b/src/routes/deposit.ts @@ -0,0 +1,79 @@ +import { Router, Request, Response } from 'express' +import { z } from 'zod' +import db from '../db' +import { requireAuth } from '../middleware/auth' +import { formatDepositReply } from '../whatsapp/formatters' + +const router = Router() + +const depositSchema = z.object({ + userId: z.string().uuid(), + txHash: z.string().min(16), + amount: z.number().positive(), + assetSymbol: z.string().min(1), + protocolName: z.string().min(1).optional(), + memo: z.string().max(280).optional(), +}) + +router.post('/', requireAuth, async (req: Request, res: Response) => { + const parsed = depositSchema.safeParse(req.body) + if (!parsed.success) { + return res.status(400).json({ + error: 'Validation error', + details: parsed.error.flatten(), + }) + } + + if (req.auth?.userId !== parsed.data.userId) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + const user = await db.user.findUnique({ + where: { id: parsed.data.userId }, + select: { id: true, network: true }, + }) + if (!user) { + return res.status(404).json({ error: 'User not found' }) + } + + const existing = await db.transaction.findUnique({ + where: { txHash: parsed.data.txHash }, + select: { id: true }, + }) + + if (existing) { + return res.status(409).json({ error: 'Duplicate transaction hash' }) + } + + const transaction = await db.transaction.create({ + data: { + userId: parsed.data.userId, + txHash: parsed.data.txHash, + type: 'DEPOSIT', + status: 'PENDING', + assetSymbol: parsed.data.assetSymbol, + amount: parsed.data.amount, + network: user.network, + protocolName: parsed.data.protocolName, + memo: parsed.data.memo, + }, + }) + + return res.status(201).json({ + transaction: { + id: transaction.id, + txHash: transaction.txHash, + status: transaction.status, + amount: Number(transaction.amount), + assetSymbol: transaction.assetSymbol, + protocolName: transaction.protocolName, + }, + whatsappReply: formatDepositReply({ + amount: Number(transaction.amount), + assetSymbol: transaction.assetSymbol, + protocolName: transaction.protocolName, + }), + }) +}) + +export default router diff --git a/src/routes/portfolio.ts b/src/routes/portfolio.ts new file mode 100644 index 0000000..aaf87a5 --- /dev/null +++ b/src/routes/portfolio.ts @@ -0,0 +1,169 @@ +import { Router, Request, Response } from 'express' +import { z } from 'zod' +import db from '../db' +import { enforceUserAccess, requireAuth } from '../middleware/auth' +import { + formatPortfolioEarningsReply, + formatPortfolioHistoryReply, + formatPortfolioReply, +} from '../whatsapp/formatters' + +const router = Router() + +const historyQuerySchema = z.object({ + period: z.enum(['7d', '30d', '90d']).default('30d'), +}) + +router.get('/:userId', requireAuth, enforceUserAccess, async (req: Request, res: Response) => { + const userId = String(req.params.userId) + const user = await db.user.findUnique({ + where: { id: userId }, + }) + + if (!user) { + return res.status(404).json({ error: 'User not found' }) + } + + const userPositions = await db.position.findMany({ + where: { userId }, + }) + + const totalBalance = userPositions.reduce((sum, position) => { + return sum + Number(position.currentValue) + }, 0) + const totalEarnings = userPositions.reduce((sum, position) => { + return sum + Number(position.yieldEarned) + }, 0) + const activePositions = userPositions.filter((p) => p.status === 'ACTIVE').length + + const positions = userPositions.map((position) => ({ + id: position.id, + protocolName: position.protocolName, + assetSymbol: position.assetSymbol, + currentValue: Number(position.currentValue), + yieldEarned: Number(position.yieldEarned), + status: position.status, + })) + + return res.status(200).json({ + userId: user.id, + totalBalance, + totalEarnings, + activePositions, + positions, + whatsappReply: formatPortfolioReply({ + totalBalance, + totalEarnings, + activePositions, + positions, + }), + }) +}) + +router.get( + '/:userId/history', + requireAuth, + enforceUserAccess, + async (req: Request, res: Response) => { + const queryParsed = historyQuerySchema.safeParse(req.query) + if (!queryParsed.success) { + return res.status(400).json({ + error: 'Validation error', + details: queryParsed.error.flatten(), + }) + } + + const user = await db.user.findUnique({ + where: { id: String(req.params.userId) }, + select: { id: true }, + }) + + if (!user) { + return res.status(404).json({ error: 'User not found' }) + } + + const userId = String(req.params.userId) + const now = Date.now() + const dayMs = 24 * 60 * 60 * 1000 + const periodDays = + queryParsed.data.period === '7d' + ? 7 + : queryParsed.data.period === '30d' + ? 30 + : 90 + const fromDate = new Date(now - periodDays * dayMs) + + const snapshots = await db.yieldSnapshot.findMany({ + where: { position: { is: { userId } }, snapshotAt: { gte: fromDate } }, + orderBy: { snapshotAt: 'desc' }, + take: 30, + }) + + const points = snapshots.map((snapshot) => ({ + date: snapshot.snapshotAt.toISOString().slice(0, 10), + yieldAmount: Number(snapshot.yieldAmount), + })) + + return res.status(200).json({ + userId, + period: queryParsed.data.period, + points, + whatsappReply: formatPortfolioHistoryReply({ + period: queryParsed.data.period, + points, + }), + }) + }, +) + +router.get( + '/:userId/earnings', + requireAuth, + enforceUserAccess, + async (req: Request, res: Response) => { + const user = await db.user.findUnique({ + where: { id: String(req.params.userId) }, + }) + + if (!user) { + return res.status(404).json({ error: 'User not found' }) + } + + const userId = String(req.params.userId) + const userPositions = await db.position.findMany({ + where: { userId }, + }) + + const snapshots = await db.yieldSnapshot.findMany({ + where: { position: { is: { userId } } }, + orderBy: { snapshotAt: 'desc' }, + take: 30, + }) + + const totalEarnings = userPositions.reduce((sum, position) => { + return sum + Number(position.yieldEarned) + }, 0) + const periodEarnings = snapshots.reduce((sum, snapshot) => { + return sum + Number(snapshot.yieldAmount) + }, 0) + const averageApy = + snapshots.length > 0 + ? snapshots.reduce((sum, snapshot) => sum + Number(snapshot.apy), 0) / + snapshots.length + : 0 + + return res.status(200).json({ + userId, + totalEarnings, + periodEarnings, + averageApy, + whatsappReply: formatPortfolioEarningsReply({ + totalEarnings, + periodEarnings, + averageApy, + }), + }) + }, +) + +export default router diff --git a/src/routes/protocols.ts b/src/routes/protocols.ts new file mode 100644 index 0000000..0e21875 --- /dev/null +++ b/src/routes/protocols.ts @@ -0,0 +1,53 @@ +import { Router, Request, Response } from 'express' +import db from '../db' +import { + formatAgentStatusReply, + formatProtocolRatesReply, +} from '../whatsapp/formatters' + +const router = Router() + +router.get('/rates', async (req: Request, res: Response) => { + const rates = await db.protocolRate.findMany({ + orderBy: { fetchedAt: 'desc' }, + take: 10, + }) + + const items = rates.map((rate) => ({ + protocolName: rate.protocolName, + assetSymbol: rate.assetSymbol, + supplyApy: Number(rate.supplyApy), + borrowApy: rate.borrowApy ? Number(rate.borrowApy) : null, + tvl: rate.tvl ? Number(rate.tvl) : null, + network: rate.network, + fetchedAt: rate.fetchedAt.toISOString(), + })) + + return res.status(200).json({ + rates: items, + whatsappReply: formatProtocolRatesReply({ rates: items }), + }) +}) + +router.get('/agent/status', async (req: Request, res: Response) => { + const latest = await db.agentLog.findFirst({ + orderBy: { createdAt: 'desc' }, + }) + + if (!latest) { + return res.status(404).json({ error: 'Agent status not found' }) + } + + const data = { + status: latest.status, + action: latest.action, + updatedAt: latest.createdAt.toISOString(), + } + + return res.status(200).json({ + ...data, + whatsappReply: formatAgentStatusReply(data), + }) +}) + +export default router diff --git a/src/routes/transactions.ts b/src/routes/transactions.ts new file mode 100644 index 0000000..feab5cc --- /dev/null +++ b/src/routes/transactions.ts @@ -0,0 +1,96 @@ +import { Router, Request, Response } from 'express' +import { z } from 'zod' +import db from '../db' +import { enforceUserAccess, requireAuth } from '../middleware/auth' +import { + formatTransactionDetailReply, + formatTransactionsReply, +} from '../whatsapp/formatters' + +const router = Router() + +const listQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(50).default(5), +}) + +router.get('/detail/:txHash', requireAuth, async (req: Request, res: Response) => { + const txHash = String(req.params.txHash) + const tx = await db.transaction.findUnique({ + where: { txHash }, + }) + + if (!tx || tx.userId !== req.auth?.userId) { + return res.status(404).json({ error: 'Transaction not found' }) + } + + const item = { + id: tx.id, + txHash: tx.txHash, + type: tx.type, + status: tx.status, + amount: Number(tx.amount), + assetSymbol: tx.assetSymbol, + protocolName: tx.protocolName, + createdAt: tx.createdAt.toISOString(), + } + + return res.status(200).json({ + transaction: item, + whatsappReply: formatTransactionDetailReply(item), + }) +}) + +router.get('/:userId', requireAuth, enforceUserAccess, async (req: Request, res: Response) => { + const userId = String(req.params.userId) + const queryParsed = listQuerySchema.safeParse(req.query) + if (!queryParsed.success) { + return res.status(400).json({ + error: 'Validation error', + details: queryParsed.error.flatten(), + }) + } + + const user = await db.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }) + if (!user) { + return res.status(404).json({ error: 'User not found' }) + } + + const page = queryParsed.data.page + const limit = queryParsed.data.limit || 5 + const skip = (page - 1) * limit + + const [total, transactions] = await Promise.all([ + db.transaction.count({ where: { userId } }), + db.transaction.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + skip, + take: limit, + }), + ]) + + const items = transactions.map((tx) => ({ + id: tx.id, + txHash: tx.txHash, + type: tx.type, + status: tx.status, + amount: Number(tx.amount), + assetSymbol: tx.assetSymbol, + protocolName: tx.protocolName, + createdAt: tx.createdAt.toISOString(), + })) + + return res.status(200).json({ + page, + limit, + total, + transactions: items, + whatsappReply: formatTransactionsReply({ page, limit, transactions: items }), + }) +}) + +export default router diff --git a/src/routes/withdraw.ts b/src/routes/withdraw.ts new file mode 100644 index 0000000..e451554 --- /dev/null +++ b/src/routes/withdraw.ts @@ -0,0 +1,68 @@ +import { Router, Request, Response } from 'express' +import { z } from 'zod' +import db from '../db' +import { requireAuth } from '../middleware/auth' +import { formatWithdrawReply } from '../whatsapp/formatters' + +const router = Router() + +const withdrawSchema = z.object({ + userId: z.string().uuid(), + amount: z.number().positive(), + assetSymbol: z.string().min(1), + protocolName: z.string().min(1).optional(), + memo: z.string().max(280).optional(), +}) + +router.post('/', requireAuth, async (req: Request, res: Response) => { + const parsed = withdrawSchema.safeParse(req.body) + if (!parsed.success) { + return res.status(400).json({ + error: 'Validation error', + details: parsed.error.flatten(), + }) + } + + if (req.auth?.userId !== parsed.data.userId) { + return res.status(401).json({ error: 'Unauthorized' }) + } + + const user = await db.user.findUnique({ + where: { id: parsed.data.userId }, + select: { id: true, network: true }, + }) + if (!user) { + return res.status(404).json({ error: 'User not found' }) + } + + const transaction = await db.transaction.create({ + data: { + userId: parsed.data.userId, + type: 'WITHDRAWAL', + status: 'PENDING', + assetSymbol: parsed.data.assetSymbol, + amount: parsed.data.amount, + network: user.network, + protocolName: parsed.data.protocolName, + memo: parsed.data.memo, + }, + }) + + return res.status(201).json({ + transaction: { + id: transaction.id, + txHash: transaction.txHash, + status: transaction.status, + amount: Number(transaction.amount), + assetSymbol: transaction.assetSymbol, + protocolName: transaction.protocolName, + }, + whatsappReply: formatWithdrawReply({ + amount: Number(transaction.amount), + assetSymbol: transaction.assetSymbol, + protocolName: transaction.protocolName, + }), + }) +}) + +export default router diff --git a/src/types/express.d.ts b/src/types/express.d.ts index 6562c1b..af04a0e 100644 --- a/src/types/express.d.ts +++ b/src/types/express.d.ts @@ -1,10 +1,18 @@ -export {}; +import type { Network } from '@prisma/client' -declare module 'express-serve-static-core' { - interface Request { - /** Authenticated user ID (UUID) */ - userId?: string; - /** Authenticated user's Stellar public key */ - stellarPubKey?: string; +declare global { + namespace Express { + interface Request { + userId?: string + stellarPubKey?: string + auth?: { + userId: string + sessionId: string + walletAddress: string + network: Network + } + } } } + +export {} diff --git a/src/whatsapp/formatters.ts b/src/whatsapp/formatters.ts new file mode 100644 index 0000000..255349d --- /dev/null +++ b/src/whatsapp/formatters.ts @@ -0,0 +1,140 @@ +type PositionSummary = { + protocolName: string + assetSymbol: string + currentValue: number +} + +type TxSummary = { + txHash: string | null + type: string + status: string + amount: number + assetSymbol: string +} + +type ProtocolRateSummary = { + protocolName: string + assetSymbol: string + supplyApy: number +} + +export function formatPortfolioReply(input: { + totalBalance: number + totalEarnings: number + activePositions: number + positions: PositionSummary[] +}): string { + const lines = input.positions.slice(0, 3).map((position) => { + return `โ€ข ${position.protocolName} ${position.assetSymbol}: $${position.currentValue.toFixed(2)}` + }) + + return [ + '๐Ÿ’ผ *Portfolio Snapshot*', + `Balance: *$${input.totalBalance.toFixed(2)}*`, + `Earnings: *$${input.totalEarnings.toFixed(2)}*`, + `Active positions: *${input.activePositions}*`, + lines.length ? lines.join('\n') : 'No active positions yet.', + ].join('\n') +} + +export function formatPortfolioHistoryReply(input: { + period: '7d' | '30d' | '90d' + points: Array<{ date: string; yieldAmount: number }> +}): string { + const lines = input.points.slice(0, 5).map((point) => { + return `โ€ข ${point.date}: +$${point.yieldAmount.toFixed(2)}` + }) + + return [ + `๐Ÿ“ˆ *History (${input.period})*`, + lines.length ? lines.join('\n') : 'No history available for this period.', + ].join('\n') +} + +export function formatPortfolioEarningsReply(input: { + totalEarnings: number + averageApy: number + periodEarnings: number +}): string { + return [ + '๐Ÿงพ *Earnings Summary*', + `Total earned: *$${input.totalEarnings.toFixed(2)}*`, + `30d earnings: *$${input.periodEarnings.toFixed(2)}*`, + `Average APY: *${(input.averageApy * 100).toFixed(2)}%*`, + ].join('\n') +} + +export function formatTransactionsReply(input: { + page: number + limit: number + transactions: TxSummary[] +}): string { + const lines = input.transactions.map((tx) => { + const hash = tx.txHash ? `${tx.txHash.slice(0, 8)}...` : 'pending' + return `โ€ข ${tx.type} ${tx.amount} ${tx.assetSymbol} (${tx.status}) [${hash}]` + }) + + return [ + `๐Ÿ“œ *Transactions* (page ${input.page}, showing ${input.limit})`, + lines.length ? lines.join('\n') : 'No transactions found.', + ].join('\n') +} + +export function formatTransactionDetailReply(input: TxSummary): string { + return [ + '๐Ÿ”Ž *Transaction Detail*', + `Type: *${input.type}*`, + `Status: *${input.status}*`, + `Amount: *${input.amount} ${input.assetSymbol}*`, + `Hash: _${input.txHash || 'pending'}_`, + ].join('\n') +} + +export function formatProtocolRatesReply(input: { + rates: ProtocolRateSummary[] +}): string { + const lines = input.rates.slice(0, 5).map((rate) => { + return `โ€ข ${rate.protocolName} ${rate.assetSymbol}: *${(rate.supplyApy * 100).toFixed(2)}% APY*` + }) + + return ['๐Ÿฆ *Protocol Rates*', lines.join('\n')].join('\n') +} + +export function formatAgentStatusReply(input: { + status: string + action: string + updatedAt: string +}): string { + return [ + '๐Ÿค– *Agent Status*', + `Latest action: *${input.action}*`, + `State: *${input.status}*`, + `Updated: _${input.updatedAt}_`, + ].join('\n') +} + +export function formatDepositReply(input: { + amount: number + assetSymbol: string + protocolName?: string | null +}): string { + return [ + 'โœ… *Deposit queued*', + `Amount: *${input.amount} ${input.assetSymbol}*`, + `Protocol: *${input.protocolName || 'Auto'}*`, + '_Your transaction is being processed._', + ].join('\n') +} + +export function formatWithdrawReply(input: { + amount: number + assetSymbol: string + protocolName?: string | null +}): string { + return [ + '๐Ÿ’ธ *Withdrawal queued*', + `Amount: *${input.amount} ${input.assetSymbol}*`, + `Protocol: *${input.protocolName || 'Auto'}*`, + '_You will receive a confirmation once settled._', + ].join('\n') +} diff --git a/tests/integration/api/api.test.ts b/tests/integration/api/api.test.ts new file mode 100644 index 0000000..e73645c --- /dev/null +++ b/tests/integration/api/api.test.ts @@ -0,0 +1,177 @@ +import request from 'supertest' + +const mockDb = { + session: { findUnique: jest.fn() }, + user: { findUnique: jest.fn() }, + position: { findMany: jest.fn() }, + yieldSnapshot: { findMany: jest.fn() }, + transaction: { + count: jest.fn(), + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + }, + protocolRate: { findMany: jest.fn() }, + agentLog: { findFirst: jest.fn() }, +} + +jest.mock('../../../src/db', () => ({ + __esModule: true, + default: mockDb, + db: mockDb, +})) + +import app from '../../../src/index' + +const userId = '550e8400-e29b-41d4-a716-446655440000' +const token = 'valid-token' + +describe('API integration routes', () => { + beforeEach(() => { + jest.clearAllMocks() + + mockDb.session.findUnique.mockResolvedValue({ + id: 'session-1', + userId, + walletAddress: 'GABC', + network: 'TESTNET', + expiresAt: new Date(Date.now() + 60_000), + user: { id: userId, isActive: true }, + }) + }) + + describe('portfolio routes', () => { + it('returns 401 without token', async () => { + const res = await request(app).get(`/api/portfolio/${userId}`) + + expect(res.status).toBe(401) + expect(res.body.error).toBe('Unauthorized') + }) + + it('returns 404 when user is missing', async () => { + mockDb.user.findUnique.mockResolvedValue(null) + + const res = await request(app) + .get(`/api/portfolio/${userId}`) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(404) + expect(res.body.error).toBe('User not found') + }) + + it('returns expected portfolio response shape', async () => { + mockDb.user.findUnique.mockResolvedValue({ + id: userId, + }) + mockDb.position.findMany.mockResolvedValue([ + { + id: 'pos-1', + protocolName: 'Blend', + assetSymbol: 'USDC', + currentValue: 5200, + yieldEarned: 200, + status: 'ACTIVE', + }, + ]) + + const res = await request(app) + .get(`/api/portfolio/${userId}`) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(200) + expect(res.body).toEqual( + expect.objectContaining({ + userId, + totalBalance: 5200, + totalEarnings: 200, + activePositions: 1, + positions: expect.any(Array), + whatsappReply: expect.any(String), + }), + ) + }) + }) + + describe('transaction routes', () => { + it('uses default pagination limit = 5', async () => { + mockDb.user.findUnique.mockResolvedValue({ id: userId }) + mockDb.transaction.count.mockResolvedValue(1) + mockDb.transaction.findMany.mockResolvedValue([ + { + id: 'tx-1', + txHash: 'hash1', + type: 'DEPOSIT', + status: 'CONFIRMED', + amount: 10, + assetSymbol: 'USDC', + protocolName: 'Blend', + createdAt: new Date(), + }, + ]) + + const res = await request(app) + .get(`/api/transactions/${userId}?page=1`) + .set('Authorization', `Bearer ${token}`) + + expect(res.status).toBe(200) + expect(res.body.limit).toBe(5) + expect(mockDb.transaction.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 5 }), + ) + }) + }) + + describe('deposit route', () => { + it('returns 409 for duplicate tx hash', async () => { + mockDb.user.findUnique.mockResolvedValue({ id: userId, network: 'TESTNET' }) + mockDb.transaction.findUnique.mockResolvedValue({ id: 'existing-tx' }) + + const res = await request(app) + .post('/api/deposit') + .set('Authorization', `Bearer ${token}`) + .send({ + userId, + txHash: 'duplicate-hash-value-0001', + amount: 100, + assetSymbol: 'USDC', + protocolName: 'Blend', + }) + + expect(res.status).toBe(409) + expect(res.body.error).toBe('Duplicate transaction hash') + }) + + it('returns whatsappReply in successful response', async () => { + mockDb.user.findUnique.mockResolvedValue({ id: userId, network: 'TESTNET' }) + mockDb.transaction.findUnique.mockResolvedValue(null) + mockDb.transaction.create.mockResolvedValue({ + id: 'tx-2', + txHash: 'new-hash-value-0002', + status: 'PENDING', + amount: 100, + assetSymbol: 'USDC', + protocolName: 'Blend', + }) + + const res = await request(app) + .post('/api/deposit') + .set('Authorization', `Bearer ${token}`) + .send({ + userId, + txHash: 'new-hash-value-0002', + amount: 100, + assetSymbol: 'USDC', + protocolName: 'Blend', + }) + + expect(res.status).toBe(201) + expect(res.body.whatsappReply).toEqual(expect.any(String)) + expect(res.body.transaction).toEqual( + expect.objectContaining({ + txHash: 'new-hash-value-0002', + amount: 100, + }), + ) + }) + }) +}) diff --git a/tests/setupEnv.ts b/tests/setupEnv.ts new file mode 100644 index 0000000..df6ce26 --- /dev/null +++ b/tests/setupEnv.ts @@ -0,0 +1,8 @@ +process.env.NODE_ENV = 'test' +process.env.STELLAR_NETWORK = 'TESTNET' +process.env.STELLAR_RPC_URL = 'https://rpc.example.com' +process.env.AGENT_SECRET_KEY = 'SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' +process.env.VAULT_CONTRACT_ID = 'CDUMMYVAULTCONTRACTID' +process.env.USDC_TOKEN_ADDRESS = 'CDUMMYUSDC' +process.env.ANTHROPIC_API_KEY = 'test-key' +process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/db' diff --git a/tsconfig.test.json b/tsconfig.test.json new file mode 100644 index 0000000..319bf34 --- /dev/null +++ b/tsconfig.test.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["src/**/*", "tests/**/*"] +} From bc473125d7c21f52c85660958b9f5a9809c5f95e Mon Sep 17 00:00:00 2001 From: Anioke Sebastian Date: Sun, 8 Mar 2026 09:27:23 +0100 Subject: [PATCH 2/2] test: add required env vars for integration setup --- tests/setupEnv.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/setupEnv.ts b/tests/setupEnv.ts index df6ce26..bf04e9c 100644 --- a/tests/setupEnv.ts +++ b/tests/setupEnv.ts @@ -1,8 +1,12 @@ process.env.NODE_ENV = 'test' process.env.STELLAR_NETWORK = 'TESTNET' process.env.STELLAR_RPC_URL = 'https://rpc.example.com' -process.env.AGENT_SECRET_KEY = 'SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' +process.env.STELLAR_AGENT_SECRET_KEY = 'SXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX' process.env.VAULT_CONTRACT_ID = 'CDUMMYVAULTCONTRACTID' process.env.USDC_TOKEN_ADDRESS = 'CDUMMYUSDC' process.env.ANTHROPIC_API_KEY = 'test-key' process.env.DATABASE_URL = 'postgresql://user:pass@localhost:5432/db' +process.env.JWT_SEED = 'test-jwt-seed' +process.env.JWT_SESSION_TTL_HOURS = '24' +process.env.JWT_NONCE_TTL_MS = '300000' +process.env.JWT_CLEANUP_INTERVAL_MS = '86400000'