From 2a7fd4c49158e22b16d3e7cff2755c54808c3b49 Mon Sep 17 00:00:00 2001 From: Wilfred007 Date: Tue, 24 Feb 2026 12:40:06 +0100 Subject: [PATCH 1/2] Database migrations, seeding, and health checks --- backend/package.json | 10 +- backend/prisma/schema.prisma | 1 - backend/prisma/seed.ts | 77 +++++++++++++ backend/src/app.ts | 16 ++- backend/src/index.ts | 24 +++- backend/src/lib/prisma.ts | 18 ++- package-lock.json | 214 ++++++++++++++++++++++++++++++++++- 7 files changed, 348 insertions(+), 12 deletions(-) create mode 100644 backend/prisma/seed.ts diff --git a/backend/package.json b/backend/package.json index 25f50fd..6856d8f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -11,16 +11,23 @@ "start": "node dist/index.js", "prisma:generate": "prisma generate", "prisma:migrate": "prisma migrate dev", + "prisma:deploy": "prisma migrate deploy", + "prisma:seed": "prisma db seed", "prisma:studio": "prisma studio" }, + "prisma": { + "seed": "tsx prisma/seed.ts" + }, "keywords": [], "author": "", "license": "ISC", "dependencies": { + "@prisma/adapter-pg": "^7.4.1", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", "express-rate-limit": "^8.2.1", + "pg": "^8.18.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "winston": "^3.11.0", @@ -31,6 +38,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^25.2.3", + "@types/pg": "^8.16.0", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.6", "nodemon": "^3.1.11", @@ -39,4 +47,4 @@ "tsx": "^4.19.2", "typescript": "^5.9.3" } -} \ No newline at end of file +} diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 9b6b832..b734a6c 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -8,7 +8,6 @@ generator client { datasource db { provider = "postgresql" - url = env("DATABASE_URL") } // User model - represents Stellar wallet addresses interacting with the protocol diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts new file mode 100644 index 0000000..6bc1da8 --- /dev/null +++ b/backend/prisma/seed.ts @@ -0,0 +1,77 @@ +import pg from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '../src/generated/prisma/index.js'; + +const connectionString = process.env.DATABASE_URL; +const pool = new pg.Pool({ connectionString }); +const adapter = new PrismaPg(pool); +const prisma = new PrismaClient({ adapter }); + +async function main() { + console.log('Seeding database...'); + + // Create example users + const user1 = await prisma.user.upsert({ + where: { publicKey: 'GBRPYH6QC6WGLH473XI3CL4B3I754SFSULN5K3X7G3X4I6SGRH3V3U12' }, + update: {}, + create: { + publicKey: 'GBRPYH6QC6WGLH473XI3CL4B3I754SFSULN5K3X7G3X4I6SGRH3V3U12', + }, + }); + + const user2 = await prisma.user.upsert({ + where: { publicKey: 'GDRS6N3K7DQ6GKH47O6E5K5G7B7H7I7J7K7L7M7N7O7P7Q7R7S7T7U7V' }, + update: {}, + create: { + publicKey: 'GDRS6N3K7DQ6GKH47O6E5K5G7B7H7I7J7K7L7M7N7O7P7Q7R7S7T7U7V', + }, + }); + + console.log({ user1, user2 }); + + // Create an example stream + const stream1 = await prisma.stream.upsert({ + where: { streamId: 101 }, + update: {}, + create: { + streamId: 101, + sender: user1.publicKey, + recipient: user2.publicKey, + tokenAddress: 'CBTM5D262F6VQY4A6E4F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X', + ratePerSecond: '100000000', // 10 XLM/sec if decimals=7 + depositedAmount: '1000000000000', + withdrawnAmount: '0', + startTime: Math.floor(Date.now() / 1000), + lastUpdateTime: Math.floor(Date.now() / 1000), + isActive: true, + }, + }); + + console.log({ stream1 }); + + // Create an example event + const event1 = await prisma.streamEvent.create({ + data: { + streamId: stream1.streamId, + eventType: 'CREATED', + amount: '1000000000000', + transactionHash: '6f7e8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r', + ledgerSequence: 123456, + timestamp: Math.floor(Date.now() / 1000), + metadata: JSON.stringify({ memo: 'Seed data' }), + }, + }); + + console.log({ event1 }); + + console.log('Seeding finished.'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/backend/src/app.ts b/backend/src/app.ts index ac558b0..5ab43b9 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -140,10 +140,19 @@ app.get('/', (req: Request, res: Response) => { */ app.get('/health', async (req: Request, res: Response) => { const { getSandboxConfig } = await import('./config/sandbox.js'); + const { prisma } = await import('./lib/prisma.js'); const sandboxConfig = getSandboxConfig(); - res.json({ - status: 'healthy', + let dbStatus = 'healthy'; + try { + await prisma.$queryRaw`SELECT 1`; + } catch (error) { + dbStatus = 'unhealthy'; + } + + const status = dbStatus === 'healthy' ? 'healthy' : 'unhealthy'; + res.status(status === 'healthy' ? 200 : 503).json({ + status, timestamp: new Date().toISOString(), uptime: process.uptime(), version: '1.0.0', @@ -151,6 +160,9 @@ app.get('/health', async (req: Request, res: Response) => { supported: ['v1'], default: 'v1', }, + services: { + database: dbStatus, + }, sandbox: { enabled: sandboxConfig.enabled, available: sandboxConfig.enabled, diff --git a/backend/src/index.ts b/backend/src/index.ts index 6757801..c3bffb9 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4,9 +4,23 @@ import logger from './logger.js'; dotenv.config(); -const port = process.env.PORT || 3001; +const startServer = async () => { + try { + // Validate database connectivity + const { prisma } = await import('./lib/prisma.js'); + await prisma.$connect(); + await prisma.$queryRaw`SELECT 1`; + logger.info('Database connection established successfully'); -app.listen(port, () => { - logger.info(`Server started on port ${port}`); - logger.info(`API Documentation available at http://localhost:${port}/api-docs`); -}); + const port = process.env.PORT || 3001; + app.listen(port, () => { + logger.info(`Server started on port ${port}`); + logger.info(`API Documentation available at http://localhost:${port}/api-docs`); + }); + } catch (error) { + logger.error('Failed to start server due to database connection error:', error); + process.exit(1); + } +}; + +startServer(); diff --git a/backend/src/lib/prisma.ts b/backend/src/lib/prisma.ts index a1a9c3b..7cbd1f6 100644 --- a/backend/src/lib/prisma.ts +++ b/backend/src/lib/prisma.ts @@ -1,10 +1,24 @@ -import { PrismaClient } from '@prisma/client'; +import pg from 'pg'; +import { PrismaPg } from '@prisma/adapter-pg'; +import { PrismaClient } from '../generated/prisma/index.js'; -const globalForPrisma = global as unknown as { prisma: PrismaClient }; +const globalForPrisma = global as unknown as { + prisma: PrismaClient; + pool: pg.Pool; +}; + +const connectionString = process.env.DATABASE_URL; + +if (!globalForPrisma.pool) { + globalForPrisma.pool = new pg.Pool({ connectionString }); +} + +const adapter = new PrismaPg(globalForPrisma.pool); export const prisma = globalForPrisma.prisma || new PrismaClient({ + adapter, log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'], }); diff --git a/package-lock.json b/package-lock.json index 22c3c41..872d039 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,10 +23,12 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "@prisma/adapter-pg": "^7.4.1", "cors": "^2.8.6", "dotenv": "^17.3.1", "express": "^5.2.1", "express-rate-limit": "^8.2.1", + "pg": "^8.18.0", "swagger-jsdoc": "^6.2.8", "swagger-ui-express": "^5.0.1", "winston": "^3.11.0", @@ -37,6 +39,7 @@ "@types/cors": "^2.8.19", "@types/express": "^5.0.6", "@types/node": "^25.2.3", + "@types/pg": "^8.16.0", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.6", "nodemon": "^3.1.11", @@ -1930,6 +1933,17 @@ "node": ">=12.4.0" } }, + "node_modules/@prisma/adapter-pg": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.4.1.tgz", + "integrity": "sha512-AH9XrqvSoBAaStn0Gm/sAnF97pDKz8uLpNmn51j1S9O9dhUva6LIxGdoDiiU9VXRIR89wAJXsvJSy+mK40m2xw==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/driver-adapter-utils": "7.4.1", + "pg": "^8.16.3", + "postgres-array": "3.0.4" + } + }, "node_modules/@prisma/client": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.4.1.tgz", @@ -1979,7 +1993,6 @@ "version": "7.4.1", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.4.1.tgz", "integrity": "sha512-qEtzO8oLouRv18JDQUC3G3Gnv+fGVscHZm/x1DBB/WT+kOvPDQLM2woX6IGgWnSMYYlrxjuALshT7G/blvY0bQ==", - "dev": true, "license": "Apache-2.0" }, "node_modules/@prisma/dev": { @@ -2008,6 +2021,15 @@ "zeptomatch": "2.1.0" } }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.4.1", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.4.1.tgz", + "integrity": "sha512-gEZOC2tnlHaZNbHUdbK8YvQphq2tKq/Ovu1YixJ/hPSutDAvNzC3R+xUeBuJ4AJp236eELMzwxb7rgo3UbRkTg==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.4.1" + } + }, "node_modules/@prisma/engines": { "version": "7.4.1", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.4.1.tgz", @@ -2547,6 +2569,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -7969,6 +8003,104 @@ "dev": true, "license": "MIT" }, + "node_modules/pg": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.18.0.tgz", + "integrity": "sha512-xqrUDL1b9MbkydY/s+VZ6v+xiMUmOUk7SS9d/1kpyQxoJ6U9AO1oIJyUWVZojbfe5Cc/oluutcgFG4L9RDP1iQ==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.11.0", + "pg-pool": "^3.11.0", + "pg-protocol": "^1.11.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.11.0.tgz", + "integrity": "sha512-kecgoJwhOpxYU21rZjULrmrBJ698U2RxXofKVzOn5UDj61BPj/qMb7diYUR1nLScCDbrztQFl1TaQZT0t1EtzQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.11.0.tgz", + "integrity": "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.11.0.tgz", + "integrity": "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pg-types/node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -8066,6 +8198,45 @@ "url": "https://github.com/sponsors/porsager" } }, + "node_modules/postgres-array": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz", + "integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -8259,6 +8430,29 @@ "destr": "^2.0.3" } }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, "node_modules/react-hot-toast": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz", @@ -8940,6 +9134,15 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/sqlstring": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", @@ -10074,6 +10277,15 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", From 3c6127d8b2cd30cd9b179d459af1583f28e19566 Mon Sep 17 00:00:00 2001 From: Wilfred007 Date: Tue, 24 Feb 2026 17:39:12 +0100 Subject: [PATCH 2/2] Dashboard integration with backend or indexer data --- frontend/app/globals.css | 34 +++ frontend/app/incoming/page.tsx | 39 +++- frontend/components/IncomingStreams.tsx | 52 ++--- .../components/dashboard/dashboard-view.tsx | 123 ++++++---- frontend/lib/api-types.ts | 39 ++++ frontend/lib/dashboard.ts | 210 +++++++++--------- 6 files changed, 320 insertions(+), 177 deletions(-) create mode 100644 frontend/lib/api-types.ts diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 904355d..667deb4 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -969,3 +969,37 @@ body { top: 1rem; } } + +.dashboard-loading-state, +.dashboard-error-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 300px; + text-align: center; + border: 1px dashed rgba(19, 38, 61, 0.2); + border-radius: 1rem; + background: rgba(255, 255, 255, 0.55); + padding: 2rem; +} + +.dashboard-loading-state p, +.dashboard-error-state p { + margin-top: 1rem; + color: #4d6985; +} + +.spinner { + width: 2.5rem; + height: 2.5rem; + border-radius: 999px; + border: 3px solid rgba(13, 83, 120, 0.1); + border-top-color: var(--accent-strong); + animation: spin 1s linear infinite; +} + +.dashboard-error-state h3 { + color: #b12f3f; + margin: 0; +} diff --git a/frontend/app/incoming/page.tsx b/frontend/app/incoming/page.tsx index f83357b..b2bdf52 100644 --- a/frontend/app/incoming/page.tsx +++ b/frontend/app/incoming/page.tsx @@ -1,13 +1,50 @@ +"use client"; + import IncomingStreams from "../../components/IncomingStreams"; import { Navbar } from "@/components/Navbar"; +import { useWallet } from "@/context/wallet-context"; +import React, { useEffect, useState } from "react"; +import { fetchDashboardData, type Stream } from "@/lib/dashboard"; export default function IncomingPage() { + const { session, status } = useWallet(); + const [streams, setStreams] = useState([]); + const [loading, setLoading] = useState(true); + const [prevKey, setPrevKey] = useState(session?.publicKey); + + // Reset loading state if public key changes (preferred over useEffect for this) + if (session?.publicKey !== prevKey) { + setPrevKey(session?.publicKey); + setLoading(true); + } + + useEffect(() => { + if (session?.publicKey) { + fetchDashboardData(session.publicKey) + .then(data => setStreams(data.incomingStreams)) + .catch(err => console.error("Failed to fetch incoming streams:", err)) + .finally(() => setLoading(false)); + } + }, [session?.publicKey]); + return (
- + {status !== "connected" ? ( +
+

Wallet Not Connected

+

Please connect your wallet in the app to view your incoming streams.

+
+ ) : loading ? ( +
+
+

Loading incoming streams...

+
+ ) : ( + + )}
diff --git a/frontend/components/IncomingStreams.tsx b/frontend/components/IncomingStreams.tsx index 1258709..8b66900 100644 --- a/frontend/components/IncomingStreams.tsx +++ b/frontend/components/IncomingStreams.tsx @@ -2,43 +2,31 @@ import React, { useState } from 'react'; import toast from "react-hot-toast"; +import type { Stream } from '@/lib/dashboard'; -interface IncomingStreamData { - id: string; - sender: string; - token: string; - rate: string; - accrued: number; - status: 'Active' | 'Completed' | 'Paused'; +interface IncomingStreamsProps { + streams: Stream[]; } -const mockIncomingStreams: IncomingStreamData[] = [ - { id: '101', sender: 'G...56yA', token: 'USDC', rate: '500/mo', accrued: 125.50, status: 'Active' }, - { id: '102', sender: 'G...Klm9', token: 'XLM', rate: '1000/mo', accrued: 450.00, status: 'Active' }, - { id: '103', sender: 'G...22Pq', token: 'EURC', rate: '200/mo', accrued: 200.00, status: 'Completed' }, - { id: '104', sender: 'G...99Zx', token: 'USDC', rate: '1200/mo', accrued: 0.00, status: 'Paused' }, - { id: '105', sender: 'G...44Tb', token: 'XLM', rate: '300/mo', accrued: 300.00, status: 'Completed' }, -]; - -const IncomingStreams: React.FC = () => { +const IncomingStreams: React.FC = ({ streams }) => { const [filter, setFilter] = useState<'All' | 'Active' | 'Completed' | 'Paused'>('All'); const filteredStreams = filter === 'All' - ? mockIncomingStreams - : mockIncomingStreams.filter(s => s.status === filter); + ? streams + : streams.filter(s => s.status === filter); const handleWithdraw = async () => { - const toastId = toast.loading("Transaction pending..."); + const toastId = toast.loading("Transaction pending..."); - try { - // Simulate async transaction (replace with real blockchain call later) - await new Promise((resolve) => setTimeout(resolve, 2000)); + try { + // Simulate async transaction (replace with real blockchain call later) + await new Promise((resolve) => setTimeout(resolve, 2000)); - toast.success("Withdrawal successful!", { id: toastId }); - } catch { - toast.error("Transaction failed.", { id: toastId }); - } -}; + toast.success("Withdrawal successful!", { id: toastId }); + } catch { + toast.error("Transaction failed.", { id: toastId }); + } + }; const handleFilterChange = (e: React.ChangeEvent) => { setFilter(e.target.value as 'All' | 'Active' | 'Completed' | 'Paused'); @@ -70,8 +58,8 @@ const IncomingStreams: React.FC = () => { Sender Token - Rate - Accrued Amount + Deposited + Withdrawn Status Actions @@ -79,10 +67,10 @@ const IncomingStreams: React.FC = () => { {filteredStreams.map((stream) => ( - {stream.sender} + {stream.id} {stream.token} - {stream.rate} - {stream.accrued.toFixed(2)} + {stream.deposited} {stream.token} + {stream.withdrawn} {stream.token} {isUnavailable ? "No data" - : formatAnalyticsValue(metric.value, metric.format)} + : formatAnalyticsValue(metric.value!, metric.format)} {isUnavailable ? metric.unavailableText : metric.detail} @@ -149,7 +149,7 @@ function renderStreams(

My Active Streams

- {snapshot.streams.length} total + {snapshot.outgoingStreams.length} total
@@ -164,7 +164,7 @@ function renderStreams( - {snapshot.streams.map((stream) => ( + {snapshot.outgoingStreams.map((stream) => ( {stream.date} @@ -236,7 +236,33 @@ function renderRecentActivity(snapshot: DashboardSnapshot) { export function DashboardView({ session, onDisconnect }: DashboardViewProps) { const [activeTab, setActiveTab] = React.useState("overview"); const [showWizard, setShowWizard] = React.useState(false); - const stats = getMockDashboardStats(session.walletId); + const [stats, setStats] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [prevKey, setPrevKey] = React.useState(session.publicKey); + + // Reset loading state during render if key changes + if (session.publicKey !== prevKey) { + setPrevKey(session.publicKey); + setLoading(true); + } + + React.useEffect(() => { + async function loadData() { + try { + setError(null); + const data = await fetchDashboardData(session.publicKey); + setStats(data); + } catch (err) { + setError("Failed to load dashboard data. Please check your connection to the FlowFi backend."); + console.error(err); + } finally { + setLoading(false); + } + } + + loadData(); + }, [session.publicKey]); const handleTopUp = (streamId: string) => { const amount = prompt(`Enter amount to add to stream ${streamId}:`); @@ -255,7 +281,7 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { // 2. Calling the contract's create_stream function // 3. Handling the transaction signing // 4. Waiting for confirmation - + // For now, simulate success await new Promise((resolve) => setTimeout(resolve, 1500)); alert(`Stream created successfully!\n\nRecipient: ${data.recipient}\nToken: ${data.token}\nAmount: ${data.amount}\nDuration: ${data.duration} ${data.durationUnit}`); @@ -264,46 +290,67 @@ export function DashboardView({ session, onDisconnect }: DashboardViewProps) { const renderContent = () => { if (activeTab === "incoming") { - return
; + return
; } if (activeTab === "overview") { - if (!stats) { - return ( -
-

No stream data yet

-

- Your account is connected, but there are no active or historical - stream records available yet. -

-
    -
  • Create your first payment stream
  • -
  • Invite a recipient to start receiving funds
  • -
  • Check back once transactions are confirmed
  • -
-
- -
-
- ); - } + if (loading) { + return ( +
+
+

Fetching your stream data...

+
+ ); + } + + if (error) { + return ( +
+

Oops! Something went wrong

+

{error}

+ +
+ ); + } + + if (!stats || (stats.outgoingStreams.length === 0 && stats.recentActivity.length === 0)) { return ( -
- {renderStats(stats)} - {renderAnalytics(stats)} - {renderStreams(stats, handleTopUp)} - {renderRecentActivity(stats)} +
+

No stream data yet

+

+ Your account is connected, but there are no active or historical + stream records available yet. +

+
    +
  • Create your first payment stream
  • +
  • Invite a recipient to start receiving funds
  • +
  • Check back once transactions are confirmed
  • +
+
+
+
); + } + return ( +
+ {renderStats(stats)} + {renderAnalytics(stats)} + {renderStreams(stats, handleTopUp)} + {renderRecentActivity(stats)} +
+ ); } - + return ( -
-

Under Construction

-

This tab is currently under development.

-
+
+

Under Construction

+

This tab is currently under development.

+
); }; diff --git a/frontend/lib/api-types.ts b/frontend/lib/api-types.ts new file mode 100644 index 0000000..1a6d48e --- /dev/null +++ b/frontend/lib/api-types.ts @@ -0,0 +1,39 @@ +export interface BackendUser { + id: string; + publicKey: string; + createdAt: string; + updatedAt: string; +} + +export type StreamEventType = "CREATED" | "TOPPED_UP" | "WITHDRAWN" | "CANCELLED" | "COMPLETED"; + +export interface BackendStreamEvent { + id: string; + streamId: number; + eventType: StreamEventType; + amount: string | null; + transactionHash: string; + ledgerSequence: number; + timestamp: number; + metadata: string | null; + createdAt: string; +} + +export interface BackendStream { + id: string; + streamId: number; + sender: string; + recipient: string; + tokenAddress: string; + ratePerSecond: string; + depositedAmount: string; + withdrawnAmount: string; + startTime: number; + lastUpdateTime: number; + isActive: boolean; + createdAt: string; + updatedAt: string; + senderUser?: BackendUser; + recipientUser?: BackendUser; + events?: BackendStreamEvent[]; +} diff --git a/frontend/lib/dashboard.ts b/frontend/lib/dashboard.ts index 92abb87..f491c8c 100644 --- a/frontend/lib/dashboard.ts +++ b/frontend/lib/dashboard.ts @@ -1,4 +1,4 @@ -import type { WalletId } from "@/lib/wallet"; +import type { BackendStream } from "./api-types"; export interface ActivityItem { id: string; @@ -26,7 +26,8 @@ export interface DashboardSnapshot { totalValueLocked: number; activeStreamsCount: number; recentActivity: ActivityItem[]; - streams: Stream[]; + outgoingStreams: Stream[]; + incomingStreams: Stream[]; } export interface DashboardAnalyticsMetric { @@ -38,108 +39,103 @@ export interface DashboardAnalyticsMetric { unavailableText: string; } -const MOCK_STATS_BY_WALLET: Record = { - freighter: { - totalSent: 12850, - totalReceived: 4720, - totalValueLocked: 32140, - activeStreamsCount: 2, - streams: [ - { - id: "stream-1", - date: "2023-10-25", - recipient: "G...ABCD", - amount: 500, - token: "USDC", - status: "Active", - deposited: 500, - withdrawn: 100, - }, - { - id: "stream-2", - date: "2023-10-26", - recipient: "G...EFGH", - amount: 1200, - token: "XLM", - status: "Active", - deposited: 1200, - withdrawn: 300, - }, - ], - recentActivity: [ - { - id: "act-1", - title: "Design Retainer", - description: "Outgoing stream settled", - amount: 250, - direction: "sent", - timestamp: "2026-02-19T13:10:00.000Z", - }, - { - id: "act-2", - title: "Community Grant", - description: "Incoming stream payout", - amount: 420, - direction: "received", - timestamp: "2026-02-18T17:45:00.000Z", - }, - { - id: "act-3", - title: "Developer Subscription", - description: "Outgoing recurring payment", - amount: 85, - direction: "sent", - timestamp: "2026-02-18T09:15:00.000Z", - }, - ], - }, - albedo: null, - xbull: { - totalSent: 2130, - totalReceived: 3890, - totalValueLocked: 5400, - activeStreamsCount: 1, - streams: [ - { - id: "stream-3", - date: "2023-10-27", - recipient: "G...IJKL", - amount: 300, - token: "EURC", - status: "Active", - deposited: 300, - withdrawn: 50, - }, - ], - recentActivity: [ - { - id: "act-4", - title: "Ops Payroll", - description: "Incoming stream payout", - amount: 630, - direction: "received", - timestamp: "2026-02-19T08:05:00.000Z", - }, - ], - }, -}; +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:3001/v1"; -export function getMockDashboardStats( - walletId: WalletId, -): DashboardSnapshot | null { - const source = MOCK_STATS_BY_WALLET[walletId]; - - if (!source) { - return null; - } +/** + * Maps a backend stream object to the frontend Stream interface. + */ +function mapBackendStreamToFrontend(s: BackendStream): Stream { + const deposited = parseFloat(s.depositedAmount) / 1e7; // Assuming 7 decimals for now, should ideally come from token config + const withdrawn = parseFloat(s.withdrawnAmount) / 1e7; return { - ...source, - recentActivity: source.recentActivity.map((activity) => ({ ...activity })), - streams: source.streams.map((stream) => ({ ...stream })), + id: s.streamId.toString(), + recipient: s.recipient.slice(0, 4) + "..." + s.recipient.slice(-4), + amount: deposited, + token: "TOKEN", // We don't have token symbols from backend yet + status: s.isActive ? "Active" : "Completed", + deposited, + withdrawn, + date: new Date(s.startTime * 1000).toISOString().split("T")[0], }; } +/** + * Fetches dashboard data for a given public key by querying both outgoing and incoming streams. + */ +export async function fetchDashboardData(publicKey: string): Promise { + try { + const [outgoingRes, incomingRes] = await Promise.all([ + fetch(`${API_BASE_URL}/streams?sender=${publicKey}`), + fetch(`${API_BASE_URL}/streams?recipient=${publicKey}`), + ]); + + if (!outgoingRes.ok || !incomingRes.ok) { + throw new Error("Failed to fetch streams from backend."); + } + + const outgoing: BackendStream[] = await outgoingRes.json(); + const incoming: BackendStream[] = await incomingRes.json(); + + const outgoingStreams = outgoing.map(mapBackendStreamToFrontend); + const incomingStreams = incoming.map(mapBackendStreamToFrontend); + + // Aggregation logic + let totalSent = 0; + let totalValueLocked = 0; + let activeStreamsCount = 0; + + outgoing.forEach(s => { + const dep = parseFloat(s.depositedAmount) / 1e7; + const withdr = parseFloat(s.withdrawnAmount) / 1e7; + totalSent += withdr; + if (s.isActive) { + totalValueLocked += (dep - withdr); + activeStreamsCount++; + } + }); + + let totalReceived = 0; + incoming.forEach(s => { + totalReceived += parseFloat(s.withdrawnAmount) / 1e7; + }); + + // Generate recent activity from streams (simplified for now) + const recentActivity: ActivityItem[] = [ + ...outgoing.map(s => ({ + id: `act-out-${s.id}`, + title: "Outgoing Stream", + description: `Stream to ${s.recipient.slice(0, 6)}...`, + amount: parseFloat(s.depositedAmount) / 1e7, + direction: "sent" as const, + timestamp: s.createdAt, + })), + ...incoming.map(s => ({ + id: `act-in-${s.id}`, + title: "Incoming Stream", + description: `Stream from ${s.sender.slice(0, 6)}...`, + amount: parseFloat(s.depositedAmount) / 1e7, + direction: "received" as const, + timestamp: s.createdAt, + })), + ].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 10); + + return { + totalSent, + totalReceived, + totalValueLocked, + activeStreamsCount, + recentActivity, + outgoingStreams, + incomingStreams, + }; + } catch (error) { + console.error("Dashboard data fetch error:", error); + throw error; + } +} + const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; export function getDashboardAnalytics( @@ -203,14 +199,16 @@ export function getDashboardAnalytics( ? snapshot.totalValueLocked / snapshot.activeStreamsCount : null; - const totalDeposited = snapshot.streams.reduce( - (sum, stream) => sum + stream.deposited, - 0, - ); - const totalWithdrawn = snapshot.streams.reduce( - (sum, stream) => sum + stream.withdrawn, - 0, - ); + const totalDeposited = [ + ...snapshot.outgoingStreams, + ...snapshot.incomingStreams, + ].reduce((sum, stream) => sum + stream.deposited, 0); + + const totalWithdrawn = [ + ...snapshot.outgoingStreams, + ...snapshot.incomingStreams, + ].reduce((sum, stream) => sum + stream.withdrawn, 0); + const utilization = totalDeposited > 0 ? totalWithdrawn / totalDeposited : null; return [