Skip to content

Commit 01ed292

Browse files
authored
Merge pull request #192 from dannyy2000/activity-history-181
feat: implement user activity history and notifications
2 parents 9706b29 + e2b2911 commit 01ed292

9 files changed

Lines changed: 4765 additions & 7066 deletions

File tree

backend/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,15 @@
4040
"@types/express": "^5.0.6",
4141
"@types/node": "^25.2.3",
4242
"@types/pg": "^8.16.0",
43+
"@types/supertest": "^7.2.0",
4344
"@types/swagger-jsdoc": "^6.0.4",
4445
"@types/swagger-ui-express": "^4.1.6",
4546
"nodemon": "^3.1.11",
4647
"prisma": "^7.4.1",
48+
"supertest": "^7.2.2",
4749
"ts-node": "^10.9.2",
4850
"tsx": "^4.19.2",
49-
"typescript": "^5.9.3"
51+
"typescript": "^5.9.3",
52+
"vitest": "^4.0.18"
5053
}
5154
}

backend/src/controllers/user.controller.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,31 @@ export const getUser = async (req: Request, res: Response, next: NextFunction) =
6262
next(error);
6363
}
6464
};
65+
66+
/**
67+
* Get user events (history)
68+
*/
69+
export const getUserEvents = async (req: Request, res: Response, next: NextFunction) => {
70+
try {
71+
const { publicKey } = req.params;
72+
73+
const events = await prisma.streamEvent.findMany({
74+
where: {
75+
stream: {
76+
OR: [
77+
{ sender: publicKey },
78+
{ recipient: publicKey }
79+
]
80+
}
81+
},
82+
orderBy: { timestamp: 'desc' },
83+
include: {
84+
stream: true
85+
}
86+
});
87+
88+
return res.status(200).json(events);
89+
} catch (error) {
90+
next(error);
91+
}
92+
};

backend/src/routes/v1/user.routes.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Router } from 'express';
2-
import { registerUser, getUser } from '../../controllers/user.controller.js';
2+
import { registerUser, getUser, getUserEvents } from '../../controllers/user.controller.js';
33

44
const router = Router();
55

@@ -66,4 +66,33 @@ const router = Router();
6666
router.post('/', registerUser);
6767
router.get('/:publicKey', getUser);
6868

69+
/**
70+
* @openapi
71+
* /v1/users/{publicKey}/events:
72+
* get:
73+
* tags:
74+
* - Users
75+
* summary: Fetch user activity history
76+
* description: Returns a chronological history of all stream events associated with the user.
77+
* parameters:
78+
* - in: path
79+
* name: publicKey
80+
* required: true
81+
* schema:
82+
* type: string
83+
* description: Stellar public key
84+
* responses:
85+
* 200:
86+
* description: List of user events
87+
* content:
88+
* application/json:
89+
* schema:
90+
* type: array
91+
* items:
92+
* $ref: '#/components/schemas/StreamEvent'
93+
* 404:
94+
* description: User not found
95+
*/
96+
router.get('/:publicKey/events', getUserEvents);
97+
6998
export default router;

frontend/components/Dashboard.tsx

Lines changed: 97 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import React from 'react';
2-
import { downloadCSV } from '../utils/csvExport';
1+
import { ActivityHistory } from './dashboard/ActivityHistory';
2+
import { fetchUserEvents } from '@/lib/dashboard';
3+
import { useWallet } from '@/context/wallet-context';
4+
import { BackendStreamEvent } from '@/lib/api-types';
35

46
interface StreamData extends Record<string, unknown> {
57
id: string;
@@ -21,6 +23,30 @@ const mockStreams: StreamData[] = [
2123
];
2224

2325
const Dashboard: React.FC = () => {
26+
const { session } = useWallet();
27+
const [activeTab, setActiveTab] = React.useState<'streams' | 'activity'>('streams');
28+
const [events, setEvents] = React.useState<BackendStreamEvent[]>([]);
29+
const [isLoadingEvents, setIsLoadingEvents] = React.useState(false);
30+
31+
React.useEffect(() => {
32+
if (activeTab === 'activity' && session?.publicKey) {
33+
loadEvents();
34+
}
35+
}, [activeTab, session?.publicKey]);
36+
37+
const loadEvents = async () => {
38+
if (!session?.publicKey) return;
39+
setIsLoadingEvents(true);
40+
try {
41+
const data = await fetchUserEvents(session.publicKey);
42+
setEvents(data);
43+
} catch (error) {
44+
console.error(error);
45+
} finally {
46+
setIsLoadingEvents(false);
47+
}
48+
};
49+
2450
const handleExport = () => {
2551
downloadCSV(mockStreams, 'flowfi-stream-history.csv');
2652
};
@@ -37,59 +63,78 @@ const Dashboard: React.FC = () => {
3763
return (
3864
<div className="p-8">
3965
<div className="flex justify-between items-center mb-6">
40-
<h1 className="text-2xl font-bold text-gray-800 dark:text-white">Stream History</h1>
41-
<button
42-
onClick={handleExport}
43-
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded shadow transition-colors"
44-
>
45-
Export CSV
46-
</button>
66+
<div className="flex items-center gap-6">
67+
<button
68+
onClick={() => setActiveTab('streams')}
69+
className={`text-2xl font-bold transition-colors ${activeTab === 'streams' ? 'text-gray-800 dark:text-white' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-200'}`}
70+
>
71+
Stream History
72+
</button>
73+
<button
74+
onClick={() => setActiveTab('activity')}
75+
className={`text-2xl font-bold transition-colors ${activeTab === 'activity' ? 'text-gray-800 dark:text-white' : 'text-gray-400 hover:text-gray-600 dark:hover:text-gray-200'}`}
76+
>
77+
Activity
78+
</button>
79+
</div>
80+
{activeTab === 'streams' && (
81+
<button
82+
onClick={handleExport}
83+
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-4 rounded shadow transition-colors"
84+
>
85+
Export CSV
86+
</button>
87+
)}
4788
</div>
4889

49-
<div className="overflow-x-auto bg-white dark:bg-gray-800 shadow rounded-lg">
50-
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
51-
<thead className="bg-gray-50 dark:bg-gray-900">
52-
<tr>
53-
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
54-
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Recipient</th>
55-
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Deposited</th>
56-
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Withdrawn</th>
57-
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Token</th>
58-
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
59-
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
60-
</tr>
61-
</thead>
62-
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
63-
{mockStreams.map((stream) => (
64-
<tr key={stream.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
65-
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{stream.date}</td>
66-
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">{stream.recipient}</td>
67-
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 font-semibold">{stream.deposited} {stream.token}</td>
68-
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{stream.withdrawn} {stream.token}</td>
69-
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{stream.token}</td>
70-
<td className="px-6 py-4 whitespace-nowrap text-sm">
71-
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
72-
${stream.status === 'Active' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
73-
stream.status === 'Completed' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
74-
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'}`}>
75-
{stream.status}
76-
</span>
77-
</td>
78-
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
79-
{stream.status === 'Active' && (
80-
<button
81-
onClick={() => handleTopUp(stream.id)}
82-
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300 bg-green-50 dark:bg-green-900/20 px-3 py-1 rounded-md transition-colors font-semibold"
83-
>
84-
Add Funds
85-
</button>
86-
)}
87-
</td>
90+
{activeTab === 'streams' ? (
91+
<div className="overflow-x-auto bg-white dark:bg-gray-800 shadow rounded-lg">
92+
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
93+
<thead className="bg-gray-50 dark:bg-gray-900">
94+
<tr>
95+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
96+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Recipient</th>
97+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Deposited</th>
98+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Withdrawn</th>
99+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Token</th>
100+
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
101+
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
88102
</tr>
89-
))}
90-
</tbody>
91-
</table>
92-
</div>
103+
</thead>
104+
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
105+
{mockStreams.map((stream) => (
106+
<tr key={stream.id} className="hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
107+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{stream.date}</td>
108+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400 font-mono">{stream.recipient}</td>
109+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100 font-semibold">{stream.deposited} {stream.token}</td>
110+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{stream.withdrawn} {stream.token}</td>
111+
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{stream.token}</td>
112+
<td className="px-6 py-4 whitespace-nowrap text-sm">
113+
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full
114+
${stream.status === 'Active' ? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' :
115+
stream.status === 'Completed' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' :
116+
'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'}`}>
117+
{stream.status}
118+
</span>
119+
</td>
120+
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
121+
{stream.status === 'Active' && (
122+
<button
123+
onClick={() => handleTopUp(stream.id)}
124+
className="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-300 bg-green-50 dark:bg-green-900/20 px-3 py-1 rounded-md transition-colors font-semibold"
125+
>
126+
Add Funds
127+
</button>
128+
)}
129+
</td>
130+
</tr>
131+
))}
132+
</tbody>
133+
</table>
134+
</div>
135+
) : (
136+
<ActivityHistory events={events} isLoading={isLoadingEvents} />
137+
)}
93138
</div>
94139
);
95140
};

frontend/components/Navbar.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import React from "react";
1+
import { NotificationDropdown } from "./NotificationDropdown";
2+
import { useWallet } from "@/context/wallet-context";
23
import { Button } from "./ui/Button";
34
import { ModeToggle } from "./ModeToggle";
45
import { WalletButton } from "./wallet/WalletButton";
56

67
export const Navbar = () => {
8+
const { session, status } = useWallet();
9+
710
return (
811
<nav className="sticky top-0 z-50 flex items-center justify-between px-6 py-4 backdrop-blur-md md:px-12 border-b border-glass-border bg-background/50">
912
<div className="flex items-center gap-2">
@@ -44,6 +47,9 @@ export const Navbar = () => {
4447
</div>
4548

4649
<div className="flex items-center gap-4">
50+
{status === "connected" && session?.publicKey && (
51+
<NotificationDropdown publicKey={session.publicKey} />
52+
)}
4753
<Button variant="ghost" className="hidden sm:inline-flex">
4854
Log In
4955
</Button>
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { BackendStreamEvent } from '@/lib/api-types';
3+
import { fetchUserEvents } from '@/lib/dashboard';
4+
import { Button } from './ui/Button';
5+
6+
interface NotificationDropdownProps {
7+
publicKey: string;
8+
}
9+
10+
export const NotificationDropdown: React.FC<NotificationDropdownProps> = ({ publicKey }) => {
11+
const [isOpen, setIsOpen] = useState(false);
12+
const [events, setEvents] = useState<BackendStreamEvent[]>([]);
13+
const [isLoading, setIsLoading] = useState(false);
14+
15+
useEffect(() => {
16+
if (isOpen && publicKey) {
17+
loadEvents();
18+
}
19+
}, [isOpen, publicKey]);
20+
21+
const loadEvents = async () => {
22+
setIsLoading(true);
23+
try {
24+
const data = await fetchUserEvents(publicKey);
25+
setEvents(data.slice(0, 5)); // Show only last 5
26+
} catch (error) {
27+
console.error(error);
28+
} finally {
29+
setIsLoading(false);
30+
}
31+
};
32+
33+
const formatEventMessage = (event: BackendStreamEvent) => {
34+
const amount = event.amount ? parseFloat(event.amount) / 1e7 : 0;
35+
switch (event.eventType) {
36+
case 'CREATED': return `New stream #${event.streamId}`;
37+
case 'TOPPED_UP': return `Topped up #${event.streamId}`;
38+
case 'WITHDRAWN': return `Withdrew ${amount} from #${event.streamId}`;
39+
case 'CANCELLED': return `Cancelled #${event.streamId}`;
40+
default: return `Event on #${event.streamId}`;
41+
}
42+
};
43+
44+
return (
45+
<div className="relative">
46+
<button
47+
onClick={() => setIsOpen(!isOpen)}
48+
className="relative p-2 text-slate-400 hover:text-accent transition-colors"
49+
>
50+
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
51+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />
52+
</svg>
53+
{events.length > 0 && (
54+
<span className="absolute top-0 right-0 h-3 w-3 bg-accent rounded-full border-2 border-background"></span>
55+
)}
56+
</button>
57+
58+
{isOpen && (
59+
<div className="absolute right-0 mt-2 w-80 bg-background/95 backdrop-blur-md border border-glass-border rounded-2xl shadow-2xl z-[100] overflow-hidden animate-in fade-in slide-in-from-top-2">
60+
<div className="p-4 border-b border-glass-border flex justify-between items-center">
61+
<h3 className="font-bold text-white">Notifications</h3>
62+
<button
63+
onClick={() => setIsOpen(false)}
64+
className="text-slate-400 hover:text-white"
65+
>
66+
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
67+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
68+
</svg>
69+
</button>
70+
</div>
71+
<div className="max-h-96 overflow-y-auto">
72+
{isLoading ? (
73+
<div className="p-8 flex justify-center">
74+
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-accent"></div>
75+
</div>
76+
) : events.length > 0 ? (
77+
<div className="divide-y divide-glass-border">
78+
{events.map((event) => (
79+
<div key={event.id} className="p-4 hover:bg-white/5 transition-colors">
80+
<p className="text-sm text-white font-medium">{formatEventMessage(event)}</p>
81+
<p className="text-xs text-slate-400 mt-1">
82+
{new Date(event.timestamp * 1000).toLocaleString()}
83+
</p>
84+
</div>
85+
))}
86+
</div>
87+
) : (
88+
<div className="p-8 text-center text-slate-400 text-sm">
89+
No new notifications
90+
</div>
91+
)}
92+
</div>
93+
<div className="p-3 border-t border-glass-border">
94+
<Button variant="ghost" size="sm" className="w-full text-xs">
95+
View All Activity
96+
</Button>
97+
</div>
98+
</div>
99+
)}
100+
</div>
101+
);
102+
};

0 commit comments

Comments
 (0)