Skip to content

Commit bfa0992

Browse files
authored
Merge branch 'main' into oracle-price-sanity-check
2 parents 2091821 + 05bce47 commit bfa0992

51 files changed

Lines changed: 2044 additions & 1032 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

backend/src/api/debug.routes.ts

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,11 +51,10 @@ debugRouter.post('/debug/notifications/test', blockDebugInProduction, requireAdm
5151
}
5252
})
5353

54-
debugRouter.get('/debug/coingecko-test', blockDebugInProduction, async (req: Request, res: Response) => {
54+
debugRouter.get('/debug/coingecko-test', blockDebugInProduction, requireAdmin, async (req: Request, res: Response) => {
5555
try {
5656
const apiKey = process.env.COINGECKO_API_KEY
5757

58-
// Test direct API call
5958
const testUrl = apiKey ?
6059
'https://pro-api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd' :
6160
'https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=usd'
@@ -75,20 +74,16 @@ debugRouter.get('/debug/coingecko-test', blockDebugInProduction, async (req: Req
7574
const data = await fetchResponse.json()
7675

7776
const response = {
78-
apiKeySet: !!apiKey,
79-
testUrl,
8077
responseStatus: fetchResponse.status,
8178
responseData: data
8279
}
8380
return ok(res, redactObject(response))
8481
} catch (error) {
85-
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error), {
86-
stack: error instanceof Error ? error.stack : String(error)
87-
})
82+
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
8883
}
8984
})
9085

91-
debugRouter.get('/debug/force-fresh-prices', blockDebugInProduction, async (req: Request, res: Response) => {
86+
debugRouter.get('/debug/force-fresh-prices', blockDebugInProduction, requireAdmin, async (req: Request, res: Response) => {
9287
try {
9388
logger.info('[DEBUG] Clearing cache and forcing fresh prices...')
9489

@@ -112,7 +107,7 @@ debugRouter.get('/debug/force-fresh-prices', blockDebugInProduction, async (req:
112107
}
113108
})
114109

115-
debugRouter.get('/debug/reflector-test', blockDebugInProduction, async (req: Request, res: Response) => {
110+
debugRouter.get('/debug/reflector-test', blockDebugInProduction, requireAdmin, async (req: Request, res: Response) => {
116111
try {
117112
logger.info('[DEBUG] Testing reflector service...')
118113

@@ -133,23 +128,20 @@ debugRouter.get('/debug/reflector-test', blockDebugInProduction, async (req: Req
133128
}
134129
})
135130

136-
debugRouter.get('/debug/env', blockDebugInProduction, async (req: Request, res: Response) => {
131+
debugRouter.get('/debug/env', blockDebugInProduction, requireAdmin, async (req: Request, res: Response) => {
137132
try {
138133
const response = {
139134
environment: global.process.env.NODE_ENV,
140-
apiKeySet: !!global.process.env.COINGECKO_API_KEY,
141135
autoRebalancerEnabled: !!autoRebalancer,
142-
autoRebalancerRunning: autoRebalancer ? autoRebalancer.getStatus().isRunning : false,
143-
enableAutoRebalancer: global.process.env.ENABLE_AUTO_REBALANCER,
144-
port: global.process.env.PORT
136+
autoRebalancerRunning: autoRebalancer ? autoRebalancer.getStatus().isRunning : false
145137
}
146138
return ok(res, redactObject(response))
147139
} catch (error) {
148140
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
149141
}
150142
})
151143

152-
debugRouter.get('/debug/auto-rebalancer-test', blockDebugInProduction, async (req: Request, res: Response) => {
144+
debugRouter.get('/debug/auto-rebalancer-test', blockDebugInProduction, requireAdmin, async (req: Request, res: Response) => {
153145
try {
154146
if (!autoRebalancer) {
155147
return fail(res, 500, 'INTERNAL_ERROR', 'Auto-rebalancer not initialized', {

backend/src/api/portfolios.routes.ts

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,9 @@ portfoliosRouter.get('/portfolios', async (req: Request, res: Response) => {
4646
const search = req.query.search as string || ''
4747
const limit = parseInt(req.query.limit as string) || 20
4848
const offset = parseInt(req.query.offset as string) || 0
49+
const includeArchived = req.query.include_archived === 'true'
4950

50-
const portfolios = await portfolioStorage.searchPortfolios(search, limit, offset)
51+
const portfolios = await portfolioStorage.searchPortfolios(search, limit, offset, includeArchived)
5152
return ok(res, { portfolios, limit, offset })
5253
} catch (error) {
5354
logger.error('[ERROR] Search portfolios failed', { error: getErrorObject(error) })
@@ -343,6 +344,66 @@ portfoliosRouter.get('/portfolio/:id/share', async (req: Request, res: Response)
343344
}
344345
})
345346

347+
// ================================
348+
// ARCHIVE / RESTORE ROUTES
349+
// ================================
350+
351+
portfoliosRouter.delete('/portfolio/:id', ...protectedWriteLimiter, async (req: Request, res: Response) => {
352+
try {
353+
const portfolioId = req.params.id
354+
if (!portfolioId) return fail(res, 400, 'VALIDATION_ERROR', 'Portfolio ID required')
355+
356+
const portfolio = await portfolioStorage.getPortfolio(portfolioId)
357+
if (!portfolio) return fail(res, 404, 'NOT_FOUND', 'Portfolio not found')
358+
359+
const authConfig = getAuthConfig()
360+
if (authConfig.enabled && (!req.user || portfolio.userAddress !== req.user.address)) {
361+
return fail(res, 403, 'FORBIDDEN', 'You can only archive your own portfolio')
362+
}
363+
364+
if (portfolio.archivedAt) {
365+
return fail(res, 400, 'ALREADY_ARCHIVED', 'Portfolio is already archived')
366+
}
367+
368+
const archived = await portfolioStorage.archivePortfolio(portfolioId)
369+
if (!archived) return fail(res, 500, 'INTERNAL_ERROR', 'Failed to archive portfolio')
370+
371+
logger.info('[ARCHIVE] Portfolio archived', { portfolioId })
372+
return ok(res, { portfolioId, status: 'archived', archivedAt: new Date().toISOString() })
373+
} catch (error) {
374+
logger.error('[ERROR] Archive portfolio failed', { error: getErrorObject(error) })
375+
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
376+
}
377+
})
378+
379+
portfoliosRouter.post('/portfolio/:id/restore', ...protectedWriteLimiter, async (req: Request, res: Response) => {
380+
try {
381+
const portfolioId = req.params.id
382+
if (!portfolioId) return fail(res, 400, 'VALIDATION_ERROR', 'Portfolio ID required')
383+
384+
const portfolio = await portfolioStorage.getPortfolio(portfolioId)
385+
if (!portfolio) return fail(res, 404, 'NOT_FOUND', 'Portfolio not found')
386+
387+
const authConfig = getAuthConfig()
388+
if (authConfig.enabled && (!req.user || portfolio.userAddress !== req.user.address)) {
389+
return fail(res, 403, 'FORBIDDEN', 'You can only restore your own portfolio')
390+
}
391+
392+
if (!portfolio.archivedAt) {
393+
return fail(res, 400, 'NOT_ARCHIVED', 'Portfolio is not archived')
394+
}
395+
396+
const restored = await portfolioStorage.restorePortfolio(portfolioId)
397+
if (!restored) return fail(res, 500, 'INTERNAL_ERROR', 'Failed to restore portfolio')
398+
399+
logger.info('[RESTORE] Portfolio restored', { portfolioId })
400+
return ok(res, { portfolioId, status: 'restored' })
401+
} catch (error) {
402+
logger.error('[ERROR] Restore portfolio failed', { error: getErrorObject(error) })
403+
return fail(res, 500, 'INTERNAL_ERROR', getErrorMessage(error))
404+
}
405+
})
406+
346407
// ================================
347408
// DRAFT PORTFOLIO ROUTES
348409
// ================================
@@ -559,7 +620,8 @@ portfoliosRouter.get('/user/:address/portfolios', async (req: Request, res: Resp
559620
}
560621
}
561622

562-
const list = await portfolioStorage.getUserPortfolios(address)
623+
const includeArchived = req.query.include_archived === 'true'
624+
const list = await portfolioStorage.getUserPortfolios(address, includeArchived)
563625

564626
return ok(res, { portfolios: list })
565627
} catch (error) {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Migration: 014_portfolio_archiving (down)
2+
-- Description: Rollback for portfolio archiving changes.
3+
-- Rollback for: 014_portfolio_archiving.up.sql
4+
5+
DROP INDEX IF EXISTS idx_portfolios_archived_at;
6+
ALTER TABLE portfolios DROP COLUMN IF EXISTS archived_at;
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
-- Migration: 014_portfolio_archiving (up)
2+
-- Description: Add soft-delete / archive support to portfolios.
3+
-- Rollback: See 014_portfolio_archiving.down.sql
4+
5+
ALTER TABLE portfolios ADD COLUMN archived_at TIMESTAMPTZ;
6+
7+
CREATE INDEX idx_portfolios_archived_at ON portfolios(archived_at)
8+
WHERE archived_at IS NOT NULL;

backend/src/db/portfolioDb.ts

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface PortfolioRow {
1616
version: number
1717
strategy?: string
1818
strategy_config?: Record<string, unknown>
19+
archived_at?: Date
1920
}
2021

2122
function rowToPortfolio(r: PortfolioRow) {
@@ -33,7 +34,8 @@ function rowToPortfolio(r: PortfolioRow) {
3334
lastRebalance: r.last_rebalance.toISOString(),
3435
version: r.version ?? 1,
3536
strategy: (r.strategy as import('../types/index.js').RebalanceStrategyType) || 'threshold',
36-
strategyConfig: r.strategy_config || undefined
37+
strategyConfig: r.strategy_config || undefined,
38+
archivedAt: r.archived_at ? r.archived_at.toISOString() : undefined
3739
}
3840
}
3941

@@ -66,19 +68,38 @@ export async function dbGetPortfolio(id: string) {
6668
return row ? rowToPortfolio(row) : undefined
6769
}
6870

69-
export async function dbGetUserPortfolios(userAddress: string) {
70-
const result = await query<PortfolioRow>(
71-
'SELECT * FROM portfolios WHERE user_address = $1 ORDER BY created_at ASC',
72-
[userAddress]
73-
)
71+
export async function dbGetUserPortfolios(userAddress: string, includeArchived = false) {
72+
const sql = includeArchived
73+
? 'SELECT * FROM portfolios WHERE user_address = $1 ORDER BY created_at ASC'
74+
: 'SELECT * FROM portfolios WHERE user_address = $1 AND archived_at IS NULL ORDER BY created_at ASC'
75+
const result = await query<PortfolioRow>(sql, [userAddress])
7476
return result.rows.map(rowToPortfolio)
7577
}
7678

77-
export async function dbGetAllPortfolios() {
78-
const result = await query<PortfolioRow>('SELECT * FROM portfolios ORDER BY created_at ASC')
79+
export async function dbGetAllPortfolios(includeArchived = false) {
80+
const sql = includeArchived
81+
? 'SELECT * FROM portfolios ORDER BY created_at ASC'
82+
: 'SELECT * FROM portfolios WHERE archived_at IS NULL ORDER BY created_at ASC'
83+
const result = await query<PortfolioRow>(sql)
7984
return result.rows.map(rowToPortfolio)
8085
}
8186

87+
export async function dbArchivePortfolio(id: string): Promise<boolean> {
88+
const result = await query(
89+
'UPDATE portfolios SET archived_at = NOW() WHERE id = $1 AND archived_at IS NULL',
90+
[id]
91+
)
92+
return (result.rowCount ?? 0) > 0
93+
}
94+
95+
export async function dbRestorePortfolio(id: string): Promise<boolean> {
96+
const result = await query(
97+
'UPDATE portfolios SET archived_at = NULL WHERE id = $1',
98+
[id]
99+
)
100+
return (result.rowCount ?? 0) > 0
101+
}
102+
82103
/**
83104
* Update a portfolio record.
84105
*
@@ -185,18 +206,19 @@ export async function dbDeletePortfolio(id: string) {
185206
return (result.rowCount ?? 0) > 0
186207
}
187208

188-
export async function dbSearchPortfolios(searchQuery: string, limit: number, offset: number) {
209+
export async function dbSearchPortfolios(searchQuery: string, limit: number, offset: number, includeArchived = false) {
210+
const archivedFilter = includeArchived ? '' : ' AND archived_at IS NULL'
189211
if (!searchQuery) {
190212
const result = await query<PortfolioRow>(
191-
'SELECT * FROM portfolios ORDER BY created_at DESC LIMIT $1 OFFSET $2',
213+
`SELECT * FROM portfolios WHERE 1=1${archivedFilter} ORDER BY created_at DESC LIMIT $1 OFFSET $2`,
192214
[limit, offset]
193215
)
194216
return result.rows.map(rowToPortfolio)
195217
}
196218

197219
const result = await query<PortfolioRow>(
198220
`SELECT * FROM portfolios
199-
WHERE search_vector @@ plainto_tsquery('english', $1)
221+
WHERE search_vector @@ plainto_tsquery('english', $1)${archivedFilter}
200222
ORDER BY ts_rank(search_vector, plainto_tsquery('english', $1)) DESC, created_at DESC
201223
LIMIT $2 OFFSET $3`,
202224
[searchQuery, limit, offset]

backend/src/db/schema.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ CREATE TABLE IF NOT EXISTS notification_logs (
8080
CREATE INDEX IF NOT EXISTS idx_notification_logs_user ON notification_logs(user_id);
8181
CREATE INDEX IF NOT EXISTS idx_notification_logs_created_at ON notification_logs(created_at DESC);
8282

83+
ALTER TABLE portfolios ADD COLUMN IF NOT EXISTS archived_at TIMESTAMPTZ;
84+
CREATE INDEX IF NOT EXISTS idx_portfolios_archived_at ON portfolios(archived_at) WHERE archived_at IS NOT NULL;
85+
8386
CREATE TABLE IF NOT EXISTS portfolio_drafts (
8487
id VARCHAR(64) PRIMARY KEY,
8588
user_address VARCHAR(256) NOT NULL,

backend/src/middleware/idempotency.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,17 @@ export const idempotencyMiddleware: RequestHandler = async (req, res, next) => {
2626
?? (req.headers['x-public-key'] as string | undefined)
2727
?? 'anonymous'
2828

29+
const scopedKey = `${requestUser}:${key}`
30+
2931
const requestHash = createHash('sha256')
3032
.update(req.method)
3133
.update(req.path)
3234
.update(stableStringify(req.body ?? {}))
3335
.update(requestUser)
3436
.digest('hex')
3537

36-
const existingRedis = await redisGetIdempotencyResult(key)
37-
const existing = existingRedis ?? dbGetIdempotencyResult(key)
38+
const existingRedis = await redisGetIdempotencyResult(scopedKey)
39+
const existing = existingRedis ?? dbGetIdempotencyResult(scopedKey)
3840

3941
if (existing) {
4042
if (existing.requestHash !== requestHash) {
@@ -65,10 +67,10 @@ export const idempotencyMiddleware: RequestHandler = async (req, res, next) => {
6567
res.json = (body: unknown) => {
6668
try {
6769
dbStoreIdempotencyResult(
68-
key, requestHash, req.method, req.path, res.statusCode, body
70+
scopedKey, requestHash, req.method, req.path, res.statusCode, body
6971
)
7072
redisStoreIdempotencyResult(
71-
key, requestHash, req.method, req.path, res.statusCode, body
73+
scopedKey, requestHash, req.method, req.path, res.statusCode, body
7274
)
7375
} catch {
7476
// Never fail the actual request due to idempotency storage errors

0 commit comments

Comments
 (0)