Skip to content

Commit 9a4ba8b

Browse files
Merge pull request #216 from blockful/dashboard-metrics
feat: add dashboard metrics
2 parents b89286d + 9e5380a commit 9a4ba8b

36 files changed

Lines changed: 3197 additions & 1 deletion

PR_DESCRIPTION.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Dashboard Metrics Implementation
2+
3+
## Summary
4+
5+
Adds a read-only Next.js dashboard for viewing notification system metrics. Provides real-time insights into user growth, DAO subscriptions, notification activity, and user engagement through interactive charts and tables.
6+
7+
## Features
8+
9+
- **Metrics**: Summary stats, user growth tracking, DAO analytics, notification activity, channel distribution
10+
- **User Management**: User table with ENS name resolution and address tracking
11+
- **Security**: Read-only database access with query validation (only `SELECT` operations allowed)
12+
- **UI**: Modern Next.js 14 app with Recharts visualizations (bar, line, pie charts) and Tailwind CSS
13+
- **Performance**: Query caching (5min TTL), ENS resolution caching, batch processing
14+
15+
## Tech Stack
16+
17+
- Next.js 14 (App Router), TypeScript, Tailwind CSS, Recharts
18+
- PostgreSQL read-only connection
19+
- Cookie-based authentication via `DASHBOARD_SECRET`
20+
21+
## API Endpoints
22+
23+
- `GET /api/metrics/summary` - Overall system metrics
24+
- `GET /api/metrics/growth` - User growth over time
25+
- `GET /api/metrics/users` - User details with pagination
26+
- `GET /api/metrics/daos` - DAO subscription metrics
27+
- `GET /api/metrics/notifications-by-dao` - Notification distribution
28+
- `GET /api/metrics/notification-activity` - Activity timeline
29+
- `POST /api/auth` - Authentication
30+
31+
## Testing
32+
33+
✅ Comprehensive test coverage for metrics functions and ENS resolver
34+
✅ All packages build successfully
35+
36+
## Environment Variables
37+
38+
```env
39+
DATABASE_URL=postgresql://... # Read-only PostgreSQL connection
40+
DASHBOARD_SECRET=your-secret-here # Authentication password
41+
```
42+
43+
**Note**: No database migrations required. Dashboard connects to existing database in read-only mode.

apps/dashboard/README.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Dashboard
2+
3+
A read-only dashboard for viewing notification system metrics.
4+
5+
## Read-Only Enforcement
6+
7+
**This dashboard is strictly read-only and never writes to the database.**
8+
9+
### Safety Measures
10+
11+
1. **Query Validation**: All database queries are validated to ensure only `SELECT` and `WITH` statements are executed
12+
2. **API Routes**: All metrics API routes are `GET` requests only
13+
3. **No Write Operations**: The codebase contains no `INSERT`, `UPDATE`, `DELETE`, `CREATE`, `ALTER`, `DROP`, or other write operations
14+
15+
### Database Access
16+
17+
- The dashboard connects to the database using a read-only connection
18+
- All queries go through the `query()` function in `src/lib/db.ts` which validates read-only operations
19+
- Any attempt to execute a write operation will throw an error
20+
21+
### Authentication
22+
23+
- The dashboard uses cookie-based authentication (no database writes)
24+
- Login credentials are validated against `DASHBOARD_SECRET` environment variable
25+
- Authentication cookies are set in memory only
26+
27+
## Database Queries Guide
28+
29+
This section documents every database query used by the dashboard and the intent
30+
behind each one. If the schema changes, use this to map the same business
31+
meaning to the new model.
32+
33+
All queries live in `src/lib/metrics.ts` and are read-only.
34+
35+
### Summary Metrics
36+
37+
- Users total: `SELECT COUNT(*) FROM users`
38+
- Goal: total number of user records in the system.
39+
- Active subscriptions total: `SELECT COUNT(*) FROM user_preferences WHERE is_active = true`
40+
- Goal: total active DAO subscriptions across all users.
41+
- Active addresses total: `SELECT COUNT(*) FROM user_addresses WHERE is_active = true`
42+
- Goal: total active blockchain addresses linked to users.
43+
- Notifications total: `SELECT COUNT(*) FROM notifications`
44+
- Goal: total notifications created (all time).
45+
46+
### Channel Distribution
47+
48+
- Query: `SELECT channel, COUNT(*) FROM users GROUP BY channel ORDER BY count DESC`
49+
- Goal: how many users exist per channel (e.g., slack, telegram, etc.).
50+
51+
### Engagement Distribution (addresses per user)
52+
53+
- Query built by `buildEngagementDistributionQuery()`
54+
- Goal: histogram of users grouped by number of active addresses.
55+
- Uses `user_addresses` grouped by `user_id` with `is_active = true`.
56+
57+
### User Growth (daily new users)
58+
59+
- Query: `SELECT date_trunc('day', created_at), COUNT(*) FROM users GROUP BY day ORDER BY day`
60+
- Goal: daily new user signups; turned into a cumulative series for charts.
61+
62+
### Top DAOs by active subscribers
63+
64+
- Query in `getTopDaos()`
65+
- Source: `user_preferences`
66+
- Filters: `is_active = true`, `dao_id` not null/empty, `TRIM(dao_id)`
67+
- Goal: highest subscriber counts per DAO.
68+
69+
### Notification Activity by Day
70+
71+
- Query built by `buildNotificationActivityByDayQuery(daoId?)`
72+
- Source: `notifications`
73+
- Optional filter: `dao_id = $1`
74+
- Goal: daily notification counts globally or for a specific DAO.
75+
76+
### Notification Activity by DAO
77+
78+
- Query in `buildNotificationActivityByDaoQuery(limit)`
79+
- Source: `notifications`
80+
- Aggregation: `COUNT(DISTINCT event_id)` grouped by trimmed `dao_id`
81+
- Goal: most active DAOs by number of distinct notification events.
82+
83+
### Users List + Filters (metrics table)
84+
85+
- Queries built by `buildUsersQueries()`
86+
- Base sources: `users`, `user_addresses`, `user_preferences`, `channel_workspaces`
87+
- Joins:
88+
- Addresses per user (active addresses + array of addresses)
89+
- DAO preferences per user (active dao count + array of dao_ids)
90+
- Slack workspace name via `channel_workspaces`
91+
- Filters:
92+
- DAO filter via `user_preferences` (active only)
93+
- Channel filter via `users.channel`
94+
- Address filter via `user_addresses.address` (active only)
95+
- Min/Max addresses via computed address_count
96+
- Goal: paginated list of users with derived metrics and filters for the UI.
97+
98+
## Development
99+
100+
```bash
101+
npm run dev
102+
```
103+
104+
## Build
105+
106+
```bash
107+
npm run build
108+
```
109+
110+
## Environment Variables
111+
112+
- `DATABASE_URL` - PostgreSQL connection string (read-only access recommended)
113+
- `DASHBOARD_SECRET` - Password for dashboard access
114+

apps/dashboard/env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
DATABASE_URL=postgresql://user:pass@localhost:5432/notification
2+
DASHBOARD_SECRET=change-me

apps/dashboard/next-env.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/// <reference types="next" />
2+
/// <reference types="next/image-types/global" />
3+
4+
// NOTE: This file should not be edited
5+
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

apps/dashboard/next.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/** @type {import('next').NextConfig} */
2+
const nextConfig = {
3+
reactStrictMode: true,
4+
};
5+
6+
module.exports = nextConfig;

apps/dashboard/package.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "@notification-system/dashboard",
3+
"version": "0.1.0",
4+
"private": true,
5+
"scripts": {
6+
"dev": "next dev -p 3300",
7+
"build": "next build",
8+
"start": "next start",
9+
"test": "tsx --test src/**/*.test.ts"
10+
},
11+
"dependencies": {
12+
"next": "^14.2.5",
13+
"pg": "^8.16.3",
14+
"react": "^18.3.1",
15+
"react-dom": "^18.3.1",
16+
"recharts": "^2.13.0"
17+
},
18+
"devDependencies": {
19+
"@types/node": "^20.12.12",
20+
"@types/react": "^18.3.12",
21+
"@types/react-dom": "^18.3.1",
22+
"autoprefixer": "^10.4.20",
23+
"postcss": "^8.4.49",
24+
"tailwindcss": "^3.4.14",
25+
"tsx": "^4.19.4",
26+
"typescript": "^5.8.3"
27+
}
28+
}

apps/dashboard/postcss.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
module.exports = {
2+
plugins: {
3+
tailwindcss: {},
4+
autoprefixer: {},
5+
},
6+
};

apps/dashboard/public/favicon.svg

Lines changed: 4 additions & 0 deletions
Loading
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
import { getAuthCookieValue } from '../../../lib/auth';
4+
5+
export async function POST(request: NextRequest) {
6+
const secret = process.env.DASHBOARD_SECRET;
7+
if (!secret) {
8+
return NextResponse.json(
9+
{ error: 'DASHBOARD_SECRET is not configured.' },
10+
{ status: 500 }
11+
);
12+
}
13+
14+
const formData = await request.formData();
15+
const password = formData.get('password');
16+
17+
if (typeof password !== 'string' || password !== secret) {
18+
return NextResponse.redirect(new URL('/login?error=1', request.url));
19+
}
20+
21+
const cookieValue = await getAuthCookieValue();
22+
if (!cookieValue) {
23+
return NextResponse.json(
24+
{ error: 'Failed to generate auth cookie.' },
25+
{ status: 500 }
26+
);
27+
}
28+
29+
const response = NextResponse.redirect(new URL('/', request.url));
30+
response.cookies.set('dashboard_auth', cookieValue, {
31+
httpOnly: true,
32+
sameSite: 'lax',
33+
path: '/',
34+
secure: process.env.NODE_ENV === 'production',
35+
maxAge: 60 * 60 * 24 * 7,
36+
});
37+
38+
return response;
39+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { NextRequest, NextResponse } from 'next/server';
2+
3+
import { getTopDaos } from '../../../../lib/metrics';
4+
5+
export const revalidate = 30;
6+
7+
export async function GET(request: NextRequest) {
8+
const limitParam = request.nextUrl.searchParams.get('limit');
9+
const limit = limitParam ? Math.min(Math.max(Number(limitParam), 1), 50) : 10;
10+
11+
try {
12+
const items = await getTopDaos(limit);
13+
return NextResponse.json({ items });
14+
} catch (error) {
15+
console.error('Failed to load DAO metrics:', error);
16+
return NextResponse.json({ error: 'Failed to load DAO metrics.' }, { status: 500 });
17+
}
18+
}

0 commit comments

Comments
 (0)