Skip to content

Commit ad9fd66

Browse files
authored
Merge pull request #105 from bandanadivya/feature/portfolio-export
Feature/portfolio export
2 parents 18d74db + df855e1 commit ad9fd66

20 files changed

+5488
-4605
lines changed

backend/package-lock.json

Lines changed: 4633 additions & 4514 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

backend/package.json

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
{
2+
"name": "stellar-portfolio-backend",
3+
"version": "0.1.0",
4+
"type": "module",
5+
"scripts": {
6+
"dev": "tsx watch src/index.ts",
7+
"build": "tsc",
8+
"start": "node dist/index.js",
9+
"db:migrate": "tsx src/db/migrate.ts",
10+
"db:migrate:dry-run": "tsx src/db/migrate.ts -- --dry-run",
11+
"db:migrate:rollback": "tsx src/db/migrate.ts -- --rollback",
12+
"db:migrate:status": "tsx src/db/migrate.ts -- --status",
13+
"db:setup": "tsx src/db/migrate.ts",
14+
"db:seed:e2e": "tsx src/db/seed.e2e.ts",
15+
"test": "vitest run",
16+
"test:watch": "vitest"
17+
},
18+
"dependencies": {
19+
"@stellar/stellar-sdk": "^12.0.1",
20+
"better-sqlite3": "^12.6.2",
21+
"bullmq": "^5.69.3",
22+
"cors": "^2.8.5",
23+
"dotenv": "^16.3.1",
24+
"express": "^4.18.2",
25+
"express-rate-limit": "^7.4.1",
26+
"ioredis": "^5.9.3",
27+
"jsonwebtoken": "^9.0.3",
28+
"node-cron": "^3.0.3",
29+
"nodemailer": "^8.0.1",
30+
"pdfkit": "^0.17.2",
31+
"pg": "^8.11.3",
32+
"pino": "^9.6.0",
33+
"ws": "^8.14.2",
34+
"zod": "^4.3.6"
35+
},
36+
"devDependencies": {
37+
"@types/better-sqlite3": "^7.6.13",
38+
"@types/cors": "^2.8.19",
39+
"@types/express": "^4.17.23",
40+
"@types/jsonwebtoken": "^9.0.10",
41+
"@types/node": "^20.19.13",
42+
"@types/node-cron": "^3.0.11",
43+
"@types/nodemailer": "^7.0.10",
44+
"@types/pdfkit": "^0.17.5",
45+
"@types/pg": "^8.10.9",
46+
"@types/supertest": "^2.0.12",
47+
"@types/ws": "^8.18.1",
48+
"supertest": "^6.3.3",
49+
"tsx": "^4.1.4",
50+
"typescript": "^5.2.2",
51+
"vitest": "^4.0.18"
52+
}
53+
}

backend/src/api/routes.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,10 @@ import { getQueueMetrics } from '../queue/queueMetrics.js'
1919
import { getErrorMessage, getErrorObject, parseOptionalBoolean } from '../utils/helpers.js'
2020
import { createPortfolioSchema } from './validation.js'
2121
import { rebalanceLockService } from '../services/rebalanceLock.js'
22+
import { REBALANCE_STRATEGIES } from '../services/rebalancingStrategyService.js'
2223
import type { Portfolio } from '../types/index.js'
2324
import { ok, fail } from '../utils/apiResponse.js'
25+
import { getPortfolioExport } from '../services/portfolioExportService.js'
2426

2527
const router = Router()
2628
const stellarService = new StellarService()
@@ -47,6 +49,10 @@ const parseHistorySource = (value: unknown): 'offchain' | 'simulated' | 'onchain
4749
}
4850

4951

52+
router.get('/strategies', (_req: Request, res: Response) => {
53+
return ok(res, { strategies: REBALANCE_STRATEGIES })
54+
})
55+
5056
router.get('/rebalance/history', async (req: Request, res: Response) => {
5157
try {
5258
const portfolioId = req.query.portfolioId as string
@@ -122,6 +128,7 @@ router.post('/rebalance/history/sync-onchain', requireAdmin, async (req: Request
122128
}
123129
})
124130

131+
router.post('/portfolio', writeRateLimiter, idempotencyMiddleware, async (req: Request, res: Response) => {
125132

126133
try {
127134
const parsed = createPortfolioSchema.safeParse(req.body)
@@ -139,9 +146,17 @@ router.post('/rebalance/history/sync-onchain', requireAdmin, async (req: Request
139146
: message
140147
return fail(res, 400, 'VALIDATION_ERROR', fullMessage)
141148
}
149+
const { userAddress, allocations, threshold, slippageTolerance, strategy, strategyConfig } = parsed.data
142150

143151
const slippageTolerancePercent = slippageTolerance ?? 1
144-
const portfolioId = await stellarService.createPortfolio(userAddress, allocations, threshold, slippageTolerancePercent)
152+
const portfolioId = await stellarService.createPortfolio(
153+
userAddress,
154+
allocations,
155+
threshold,
156+
slippageTolerancePercent,
157+
strategy ?? 'threshold',
158+
strategyConfig ?? {}
159+
)
145160
const mode = featureFlags.demoMode ? 'demo' : 'onchain'
146161
return ok(res, {
147162
portfolioId,
@@ -154,21 +169,50 @@ router.post('/rebalance/history/sync-onchain', requireAdmin, async (req: Request
154169
}
155170
})
156171

172+
router.get('/portfolio/:id', async (req: Request, res: Response) => {
157173

158174
try {
159175
const portfolioId = req.params.id
160176
if (!portfolioId) return fail(res, 400, 'VALIDATION_ERROR', 'Portfolio ID required')
161177
const portfolio = await stellarService.getPortfolio(portfolioId)
162178
if (!portfolio) return fail(res, 404, 'NOT_FOUND', 'Portfolio not found')
163-
main
179+
164180
return ok(res, { portfolio })
165181
} catch (error) {
166182
logger.error('[ERROR] Get portfolio failed', { error: getErrorObject(error) })
167183
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
168184
}
169185
})
170186

171-
main
187+
// Portfolio export (JSON, CSV, PDF) — GDPR data portability
188+
router.get('/portfolio/:id/export', requireJwtWhenEnabled, async (req: Request, res: Response) => {
189+
try {
190+
const portfolioId = req.params.id
191+
const format = (req.query.format as string)?.toLowerCase()
192+
if (!portfolioId) return fail(res, 400, 'VALIDATION_ERROR', 'Portfolio ID required')
193+
if (!['json', 'csv', 'pdf'].includes(format)) {
194+
return fail(res, 400, 'VALIDATION_ERROR', 'Query parameter format must be one of: json, csv, pdf')
195+
}
196+
const result = await getPortfolioExport(portfolioId, format as 'json' | 'csv' | 'pdf')
197+
if (!result) return fail(res, 404, 'NOT_FOUND', 'Portfolio not found')
198+
res.setHeader('Content-Type', result.contentType)
199+
res.setHeader('Content-Disposition', `attachment; filename="${result.filename}"`)
200+
if (Buffer.isBuffer(result.body)) {
201+
return res.send(result.body)
202+
}
203+
return res.send(result.body)
204+
} catch (error) {
205+
logger.error('[ERROR] Portfolio export failed', { error: getErrorObject(error) })
206+
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
207+
}
208+
})
209+
210+
router.get('/user/:address/portfolios', async (req: Request, res: Response) => {
211+
try {
212+
const address = req.params.address
213+
if (!address) return fail(res, 400, 'VALIDATION_ERROR', 'User address required')
214+
const list = portfolioStorage.getUserPortfolios(address)
215+
172216
return ok(res, { portfolios: list })
173217
} catch (error) {
174218
logger.error('[ERROR] Get user portfolios failed', { error: getErrorObject(error) })
@@ -200,6 +244,7 @@ router.get('/portfolio/:id/rebalance-plan', async (req: Request, res: Response)
200244
})
201245

202246
// Manual portfolio rebalance
247+
router.post('/portfolio/:id/rebalance', writeRateLimiter, idempotencyMiddleware, async (req: Request, res: Response) => {
203248

204249
try {
205250
const portfolioId = req.params.id;
@@ -692,6 +737,7 @@ router.get('/portfolio/:id/performance-summary', async (req: Request, res: Respo
692737
router.post('/notifications/subscribe', requireJwtWhenEnabled, writeRateLimiter, idempotencyMiddleware, async (req: Request, res: Response) => {
693738
try {
694739
const userId = req.user?.address ?? req.body?.userId
740+
const { emailEnabled, webhookEnabled, webhookUrl, events, emailAddress } = req.body ?? {}
695741

696742
// Validation
697743
if (!userId) {

backend/src/api/validation.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ export const createPortfolioSchema = z.object({
2323
),
2424
threshold: z.number().min(1, "Threshold must be between 1% and 50%").max(50, "Threshold must be between 1% and 50%"),
2525
slippageTolerance: z.number().min(0.1, "Slippage tolerance must be between 0.1% and 5%").max(5, "Slippage tolerance must be between 0.1% and 5%").optional(),
26+
strategy: z.enum(['threshold', 'periodic', 'volatility', 'custom']).optional(),
27+
strategyConfig: z.object({
28+
intervalDays: z.number().min(1).max(365).optional(),
29+
volatilityThresholdPct: z.number().min(1).max(100).optional(),
30+
minDaysBetweenRebalance: z.number().min(0).max(365).optional(),
31+
}).optional(),
2632
}).strict();
2733

2834
// Schema for POST /portfolio/:id/rebalance
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE portfolios
2+
DROP COLUMN IF EXISTS strategy_config,
3+
DROP COLUMN IF EXISTS strategy;
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
ALTER TABLE portfolios
2+
ADD COLUMN IF NOT EXISTS strategy VARCHAR(32) NOT NULL DEFAULT 'threshold',
3+
ADD COLUMN IF NOT EXISTS strategy_config JSONB DEFAULT '{}';
4+
5+
COMMENT ON COLUMN portfolios.strategy IS 'Rebalancing strategy: threshold, periodic, volatility, custom';
6+
COMMENT ON COLUMN portfolios.strategy_config IS 'Strategy-specific options (intervalDays, volatilityThresholdPct, etc.)';

backend/src/db/portfolioDb.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export interface PortfolioRow {
1212
created_at: Date
1313
last_rebalance: Date
1414
version: number
15+
strategy?: string
16+
strategy_config?: Record<string, unknown>
1517
}
1618

1719
function rowToPortfolio(r: PortfolioRow) {
@@ -25,7 +27,9 @@ function rowToPortfolio(r: PortfolioRow) {
2527
totalValue: Number(r.total_value),
2628
createdAt: r.created_at.toISOString(),
2729
lastRebalance: r.last_rebalance.toISOString(),
28-
version: r.version ?? 1
30+
version: r.version ?? 1,
31+
strategy: (r.strategy as import('../types/index.js').RebalanceStrategyType) || 'threshold',
32+
strategyConfig: r.strategy_config || undefined
2933
}
3034
}
3135

@@ -36,12 +40,14 @@ export async function dbCreatePortfolio(
3640
threshold: number,
3741
balances: Record<string, number>,
3842
totalValue: number,
39-
slippageTolerance: number = 1
43+
slippageTolerance: number = 1,
44+
strategy: string = 'threshold',
45+
strategyConfig: Record<string, unknown> = {}
4046
) {
4147
await query(
42-
`INSERT INTO portfolios (id, user_address, allocations, threshold, slippage_tolerance, balances, total_value, created_at, last_rebalance, version)
43-
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW(), 1)`,
44-
[id, userAddress, JSON.stringify(allocations), threshold, slippageTolerance, JSON.stringify(balances), totalValue]
48+
`INSERT INTO portfolios (id, user_address, allocations, threshold, slippage_tolerance, balances, total_value, created_at, last_rebalance, version, strategy, strategy_config)
49+
VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), NOW(), 1, $8, $9)`,
50+
[id, userAddress, JSON.stringify(allocations), threshold, slippageTolerance, JSON.stringify(balances), totalValue, strategy, JSON.stringify(strategyConfig)]
4551
)
4652
}
4753

backend/src/index.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
import 'dotenv/config'
22
import express from 'express'
33
import cors from 'cors'
4-
import swaggerUi from 'swagger-ui-express'
54
import { createServer } from 'node:http'
65
import { WebSocketServer } from 'ws'
76
import { portfolioRouter } from './api/routes.js'
8-
n
7+
import { authRouter } from './api/authRoutes.js'
98
import { errorHandler, notFound } from './middleware/errorHandler.js'
109
import { globalRateLimiter } from './middleware/rateLimit.js'
1110
import { RebalancingService } from './monitoring/rebalancer.js'
@@ -150,18 +149,6 @@ app.get('/test/coingecko', async (req, res) => {
150149
})
151150
}
152151
})
153-
// OpenAPI spec as JSON (for Postman: Import → Link → http://localhost:3000/api-docs/openapi.json)
154-
app.get('/api-docs/openapi.json', (req, res) => {
155-
res.setHeader('Content-Type', 'application/json')
156-
res.json(openApiSpec)
157-
})
158-
159-
// Swagger UI
160-
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(openApiSpec, {
161-
customCss: '.swagger-ui .topbar { display: none }',
162-
customSiteTitle: 'Stellar Portfolio Rebalancer API',
163-
}))
164-
165152
// Root route
166153
app.get('/', (req, res) => {
167154
res.json({
@@ -190,6 +177,7 @@ app.get('/', (req, res) => {
190177
// Mount API routes
191178
app.use('/api/auth', authRouter)
192179
app.use('/api', portfolioRouter)
180+
app.use('/api/v1', portfolioRouter)
193181
app.use('/api', apiErrorHandler)
194182

195183
// Legacy non-/api compatibility (redirect only)

backend/src/openapi/openapi.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"/api/rebalance/history/sync-onchain": { "post": { "tags": ["Rebalance history"], "summary": "Sync on-chain history (admin)", "responses": { "200": { "description": "OK" } } } },
3131
"/api/portfolio": { "post": { "tags": ["Portfolio"], "summary": "Create portfolio", "requestBody": { "content": { "application/json": { "schema": { "type": "object", "properties": { "userAddress": { "type": "string" }, "allocations": { "type": "object" }, "threshold": { "type": "number" }, "slippageTolerance": { "type": "number" } } } } }, "responses": { "201": { "description": "Created" } } } },
3232
"/api/portfolio/{id}": { "get": { "tags": ["Portfolio"], "summary": "Get portfolio", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], "responses": { "200": { "description": "OK" }, "404": { "description": "Not found" } } } },
33+
"/api/portfolio/{id}/export": { "get": { "tags": ["Portfolio"], "summary": "Export portfolio data (GDPR)", "description": "Download portfolio and rebalance history as JSON, CSV, or PDF. Includes timestamp and portfolio ID.", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, { "name": "format", "in": "query", "required": true, "schema": { "type": "string", "enum": ["json", "csv", "pdf"] } }], "responses": { "200": { "description": "File attachment (application/json, text/csv, or application/pdf)" }, "400": { "description": "Invalid format" }, "404": { "description": "Portfolio not found" } } } },
3334
"/api/user/{address}/portfolios": { "get": { "tags": ["Portfolio"], "summary": "List user portfolios", "parameters": [{ "name": "address", "in": "path", "required": true, "schema": { "type": "string" } }], "responses": { "200": { "description": "OK" } } } },
3435
"/api/portfolio/{id}/rebalance-plan": { "get": { "tags": ["Portfolio"], "summary": "Get rebalance plan", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], "responses": { "200": { "description": "OK" } } } },
3536
"/api/portfolio/{id}/rebalance": { "post": { "tags": ["Portfolio"], "summary": "Execute rebalance", "parameters": [{ "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }], "requestBody": { "content": { "application/json": { "schema": { "type": "object" } } } }, "responses": { "200": { "description": "OK" }, "409": { "description": "Conflict" } } } },

0 commit comments

Comments
 (0)