Skip to content

Commit b5dca8e

Browse files
authored
Merge pull request #1121 from Olamidepy/feature/clone-portfolio-endpoint
Feat; Add portfolio cloning endpoint
2 parents 05bce47 + 14acc9b commit b5dca8e

9 files changed

Lines changed: 926 additions & 1 deletion

File tree

API.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,8 @@ available price contributes zero to `total_value_usd` rather than being assumed.
487487
488488
- **POST /api/portfolio** — Create portfolio (`userAddress`, `allocations`, `threshold`, optional `slippageTolerance`). Allocations must sum to 100%; threshold 1–50%. Supports `Idempotency-Key`.
489489
- **GET /api/portfolio/{id}** — Get portfolio by ID.
490+
- **GET /api/portfolios** — List all portfolios (optional query parameter: `userAddress`).
491+
- **POST /api/portfolio/{id}/clone** — Clone an existing portfolio (optional body: `{ name }`). Supports `Idempotency-Key`.
490492
- **GET /api/user/{address}/portfolios** — List portfolios for a Stellar address. When JWT auth is enabled, the token subject must match `:address` (otherwise `403`). In demo mode, public-by-address listing is allowed only when `ALLOW_PUBLIC_USER_PORTFOLIOS_IN_DEMO` is enabled.
491493
- **GET /api/portfolios/summary** — Dashboard summary of every portfolio for one address in a single request (query: `userAddress`, required). Returns `id`, `name`, `total_value_usd`, `drift_status` (`ok`/`warning`/`critical`), and `last_rebalanced` per portfolio; empty array for an unknown address. Prices are read once from the oracle cache for the whole response. Same ownership rules as `GET /api/user/{address}/portfolios`.
492494
- **GET /api/portfolio/{id}/rebalance-plan** — Get full read-only rebalance plan (per-asset buy/sell amounts, estimated fees, estimated slippage, projected allocations, prices).

backend/openapi.json

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -869,6 +869,184 @@
869869
}
870870
}
871871
},
872+
"/api/portfolios": {
873+
"get": {
874+
"tags": [
875+
"Portfolio"
876+
],
877+
"summary": "List all portfolios",
878+
"description": "Get all portfolios, optionally filtered by user address query parameter.",
879+
"parameters": [
880+
{
881+
"name": "userAddress",
882+
"in": "query",
883+
"required": false,
884+
"schema": {
885+
"type": "string"
886+
}
887+
}
888+
],
889+
"responses": {
890+
"200": {
891+
"description": "List of portfolios",
892+
"content": {
893+
"application/json": {
894+
"schema": {
895+
"type": "object",
896+
"properties": {
897+
"success": {
898+
"type": "boolean"
899+
},
900+
"data": {
901+
"type": "object",
902+
"properties": {
903+
"portfolios": {
904+
"type": "array",
905+
"items": {
906+
"$ref": "#/components/schemas/Portfolio"
907+
}
908+
}
909+
}
910+
},
911+
"error": {
912+
"type": "object",
913+
"nullable": true
914+
},
915+
"timestamp": {
916+
"type": "string",
917+
"format": "date-time"
918+
}
919+
}
920+
}
921+
}
922+
}
923+
},
924+
"500": {
925+
"description": "Internal error",
926+
"content": {
927+
"application/json": {
928+
"schema": {
929+
"$ref": "#/components/schemas/ApiError"
930+
}
931+
}
932+
}
933+
}
934+
}
935+
}
936+
},
937+
"/api/portfolio/{id}/clone": {
938+
"post": {
939+
"tags": [
940+
"Portfolio"
941+
],
942+
"summary": "Clone portfolio",
943+
"description": "Create a new portfolio cloning the allocations, threshold, and strategy of an existing portfolio.",
944+
"parameters": [
945+
{
946+
"name": "id",
947+
"in": "path",
948+
"required": true,
949+
"schema": {
950+
"type": "string"
951+
}
952+
}
953+
],
954+
"requestBody": {
955+
"required": false,
956+
"content": {
957+
"application/json": {
958+
"schema": {
959+
"type": "object",
960+
"properties": {
961+
"name": {
962+
"type": "string",
963+
"description": "Optional new name for cloned portfolio"
964+
}
965+
}
966+
}
967+
}
968+
}
969+
},
970+
"responses": {
971+
"201": {
972+
"description": "Portfolio cloned",
973+
"content": {
974+
"application/json": {
975+
"schema": {
976+
"type": "object",
977+
"properties": {
978+
"success": {
979+
"type": "boolean",
980+
"example": true
981+
},
982+
"data": {
983+
"type": "object",
984+
"properties": {
985+
"portfolioId": {
986+
"type": "string"
987+
},
988+
"portfolio": {
989+
"$ref": "#/components/schemas/Portfolio"
990+
},
991+
"status": {
992+
"type": "string",
993+
"example": "created"
994+
},
995+
"mode": {
996+
"type": "string",
997+
"enum": [
998+
"demo",
999+
"onchain"
1000+
]
1001+
}
1002+
}
1003+
},
1004+
"error": {
1005+
"type": "object",
1006+
"nullable": true
1007+
},
1008+
"timestamp": {
1009+
"type": "string",
1010+
"format": "date-time"
1011+
}
1012+
}
1013+
}
1014+
}
1015+
}
1016+
},
1017+
"400": {
1018+
"description": "Validation error",
1019+
"content": {
1020+
"application/json": {
1021+
"schema": {
1022+
"$ref": "#/components/schemas/ApiError"
1023+
}
1024+
}
1025+
}
1026+
},
1027+
"404": {
1028+
"description": "Portfolio not found",
1029+
"content": {
1030+
"application/json": {
1031+
"schema": {
1032+
"$ref": "#/components/schemas/ApiError"
1033+
}
1034+
}
1035+
}
1036+
},
1037+
"500": {
1038+
"description": "Internal error",
1039+
"content": {
1040+
"application/json": {
1041+
"schema": {
1042+
"$ref": "#/components/schemas/ApiError"
1043+
}
1044+
}
1045+
}
1046+
}
1047+
}
1048+
}
1049+
},
8721050
"/api/user/{address}/portfolios": {
8731051
"get": {
8741052
"tags": [

backend/src/api/portfolios.routes.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { idempotencyMiddleware } from '../middleware/idempotency.js'
1111
import { requireJwt, requireJwtWhenEnabled } from '../middleware/requireJwt.js'
1212
import { protectedWriteLimiter } from '../middleware/rateLimit.js'
1313
import { validateRequest, validateQuery } from '../middleware/validate.js'
14+
import { createPortfolioSchema, clonePortfolioSchema, portfolioExportQuerySchema } from './validation.js'
1415

1516
import { getAuthConfig } from '../services/authService.js'
1617
import { getFeatureFlags } from '../config/featureFlags.js'
@@ -630,6 +631,64 @@ portfoliosRouter.get('/user/:address/portfolios', async (req: Request, res: Resp
630631
}
631632
})
632633

634+
portfoliosRouter.get('/portfolios', async (req: Request, res: Response) => {
635+
try {
636+
const address = (req.query.userAddress || req.query.address) as string | undefined
637+
let list: Portfolio[] = []
638+
if (address) {
639+
list = await portfolioStorage.getUserPortfolios(address)
640+
} else {
641+
list = await portfolioStorage.getAllPortfolios()
642+
}
643+
return ok(res, { portfolios: list })
644+
} catch (error) {
645+
logger.error('[ERROR] Get portfolios list failed', { error: getErrorObject(error) })
646+
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
647+
}
648+
})
649+
650+
portfoliosRouter.post('/portfolio/:id/clone', ...protectedWriteLimiter, idempotencyMiddleware, async (req: Request, res: Response) => {
651+
try {
652+
const portfolioId = req.params.id
653+
if (!portfolioId) return fail(res, 400, 'VALIDATION_ERROR', 'Portfolio ID required')
654+
655+
const parsed = clonePortfolioSchema.safeParse(req.body ?? {})
656+
if (!parsed.success) {
657+
const first = parsed.error.issues[0]
658+
return fail(res, 400, 'VALIDATION_ERROR', first?.message ?? 'Validation failed')
659+
}
660+
661+
const original = await portfolioStorage.getPortfolio(portfolioId)
662+
if (!original) return fail(res, 404, 'NOT_FOUND', 'Portfolio not found')
663+
664+
const authConfig = getAuthConfig()
665+
if (authConfig.enabled) {
666+
let nextCalled = false
667+
requireJwt(req, res, () => { nextCalled = true })
668+
if (!nextCalled) return
669+
670+
if (req.user?.address !== original.userAddress) {
671+
return fail(res, 403, 'FORBIDDEN', 'You can only clone your own portfolio')
672+
}
673+
}
674+
675+
const cloneName = parsed.data.name
676+
const clone = await portfolioStorage.clonePortfolio(portfolioId, cloneName)
677+
if (!clone) return fail(res, 404, 'NOT_FOUND', 'Portfolio not found')
678+
679+
const mode = featureFlags.demoMode ? 'demo' : 'onchain'
680+
return ok(res, {
681+
portfolioId: clone.id,
682+
portfolio: clone,
683+
status: 'created',
684+
mode
685+
}, { status: 201 })
686+
} catch (error) {
687+
logger.error('[ERROR] Clone portfolio failed', { error: getErrorObject(error) })
688+
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
689+
}
690+
})
691+
633692
portfoliosRouter.get('/portfolio/:id/rebalance-plan', async (req: Request, res: Response) => {
634693
try {
635694
const portfolioId = req.params.id

backend/src/api/validation.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ export const updatePortfolioSchema = createPortfolioSchema.partial().extend({
5757
version: z.number().int().min(1, "Version must be a positive integer")
5858
});
5959

60+
// Schema for POST /portfolio/:id/clone
61+
export const clonePortfolioSchema = z.object({
62+
name: z.string().trim().min(1, 'Name cannot be empty').max(100, 'Name cannot exceed 100 characters').optional()
63+
}).strict();
6064
// Schema for GET /portfolios/summary
6165
export const portfolioSummaryQuerySchema = z.object({
6266
userAddress: z.string().min(1, "userAddress is required"),

backend/src/openapi/spec.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -398,6 +398,91 @@ const spec: Record<string, any> = {
398398
},
399399
},
400400
},
401+
'/api/portfolios': {
402+
get: {
403+
tags: ['Portfolio'],
404+
summary: 'List all portfolios',
405+
description: 'Get all portfolios, optionally filtered by user address query parameter.',
406+
parameters: [{ name: 'userAddress', in: 'query', required: false, schema: { type: 'string' } }],
407+
responses: {
408+
'200': {
409+
description: 'List of portfolios',
410+
content: {
411+
'application/json': {
412+
schema: {
413+
type: 'object',
414+
properties: {
415+
success: { type: 'boolean' },
416+
data: {
417+
type: 'object',
418+
properties: {
419+
portfolios: {
420+
type: 'array',
421+
items: { $ref: '#/components/schemas/Portfolio' },
422+
},
423+
},
424+
},
425+
error: { type: 'object', nullable: true },
426+
timestamp: { type: 'string', format: 'date-time' },
427+
},
428+
},
429+
},
430+
},
431+
},
432+
'500': { description: 'Internal error', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } },
433+
},
434+
},
435+
},
436+
'/api/portfolio/{id}/clone': {
437+
post: {
438+
tags: ['Portfolio'],
439+
summary: 'Clone portfolio',
440+
description: 'Create a new portfolio cloning the allocations, threshold, and strategy of an existing portfolio.',
441+
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }],
442+
requestBody: {
443+
required: false,
444+
content: {
445+
'application/json': {
446+
schema: {
447+
type: 'object',
448+
properties: {
449+
name: { type: 'string', description: 'Optional new name for cloned portfolio' },
450+
},
451+
},
452+
},
453+
},
454+
},
455+
responses: {
456+
'201': {
457+
description: 'Portfolio cloned',
458+
content: {
459+
'application/json': {
460+
schema: {
461+
type: 'object',
462+
properties: {
463+
success: { type: 'boolean', example: true },
464+
data: {
465+
type: 'object',
466+
properties: {
467+
portfolioId: { type: 'string' },
468+
portfolio: { $ref: '#/components/schemas/Portfolio' },
469+
status: { type: 'string', example: 'created' },
470+
mode: { type: 'string', enum: ['demo', 'onchain'] },
471+
},
472+
},
473+
error: { type: 'object', nullable: true },
474+
timestamp: { type: 'string', format: 'date-time' },
475+
},
476+
},
477+
},
478+
},
479+
},
480+
'400': { description: 'Validation error', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } },
481+
'404': { description: 'Portfolio not found', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } },
482+
'500': { description: 'Internal error', content: { 'application/json': { schema: { $ref: '#/components/schemas/ApiError' } } } },
483+
},
484+
},
485+
},
401486
'/api/user/{address}/portfolios': {
402487
get: {
403488
tags: ['Portfolio'],

0 commit comments

Comments
 (0)