src
modules
cards
user
application
domain
infrastructure
···
7
7
8
8
// Create tables in dependency order using raw SQL with proper column names
9
9
const tableCreationQueries = [
10
10
+
// Users table (no dependencies)
11
11
+
sql`CREATE TABLE IF NOT EXISTS users (
12
12
+
id TEXT PRIMARY KEY,
13
13
+
handle TEXT,
14
14
+
linked_at TIMESTAMP WITH TIME ZONE NOT NULL,
15
15
+
last_login_at TIMESTAMP WITH TIME ZONE NOT NULL
16
16
+
)`,
17
17
+
10
18
// Published records table (no dependencies)
11
19
sql`CREATE TABLE IF NOT EXISTS published_records (
12
20
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
···
3
3
import {
4
4
IUserStatsRepository,
5
5
UserGrowthStatsDTO,
6
6
+
UserEngagementStatsDTO,
6
7
UserStatType,
7
8
TimeInterval,
8
9
} from '../../../domain/IUserStatsRepository';
···
11
12
statType: UserStatType;
12
13
interval?: TimeInterval;
13
14
limit?: number;
15
15
+
includeTimeSeries?: boolean; // For engagement stats
14
16
// Future parameters can be added here for other stat types
15
17
}
16
18
17
17
-
export type GetUserStatsResult = UserGrowthStatsDTO; // Will be a union type as we add more stat types
19
19
+
export type GetUserStatsResult = UserGrowthStatsDTO | UserEngagementStatsDTO; // Union type for different stat types
18
20
19
21
export class ValidationError extends Error {
20
22
constructor(message: string) {
···
40
42
case 'growth':
41
43
return await this.handleGrowthStats(query);
42
44
45
45
+
case 'engagement':
46
46
+
return await this.handleEngagementStats(query);
47
47
+
43
48
// Future stat types can be added here
44
49
// case 'activity':
45
50
// return await this.handleActivityStats(query);
46
46
-
// case 'engagement':
47
47
-
// return await this.handleEngagementStats(query);
48
51
49
52
default:
50
53
return err(
···
92
95
}
93
96
}
94
97
98
98
+
private async handleEngagementStats(
99
99
+
query: GetUserStatsQuery,
100
100
+
): Promise<Result<UserEngagementStatsDTO>> {
101
101
+
// Apply defaults
102
102
+
const interval = query.interval || 'day';
103
103
+
const limit = query.limit || 30;
104
104
+
const includeTimeSeries = query.includeTimeSeries || false;
105
105
+
106
106
+
// Validate parameters
107
107
+
if (interval && !['day', 'week', 'month'].includes(interval)) {
108
108
+
return err(new ValidationError(`Invalid interval: ${interval}`));
109
109
+
}
110
110
+
111
111
+
if (limit < 1 || limit > 365) {
112
112
+
return err(new ValidationError('Limit must be between 1 and 365'));
113
113
+
}
114
114
+
115
115
+
try {
116
116
+
const stats = await this.userStatsRepository.getUserEngagementStats({
117
117
+
interval,
118
118
+
limit,
119
119
+
includeTimeSeries,
120
120
+
});
121
121
+
122
122
+
return ok(stats);
123
123
+
} catch (error) {
124
124
+
return err(
125
125
+
new Error(
126
126
+
`Failed to retrieve engagement stats: ${error instanceof Error ? error.message : 'Unknown error'}`,
127
127
+
),
128
128
+
);
129
129
+
}
130
130
+
}
131
131
+
95
132
// Future handler methods can be added here
96
133
// private async handleActivityStats(query: GetUserStatsQuery): Promise<Result<UserActivityStatsDTO>> { ... }
97
97
-
// private async handleEngagementStats(query: GetUserStatsQuery): Promise<Result<UserEngagementStatsDTO>> { ... }
98
134
}
···
20
20
limit: number; // Number of intervals to return
21
21
}
22
22
23
23
+
// User Engagement Statistics DTOs
24
24
+
25
25
+
export interface UserEngagementDataPoint {
26
26
+
date: string;
27
27
+
activeUsers: number; // Users who created content in this period
28
28
+
newlyActivatedUsers: number; // Previously inactive users who became active
29
29
+
cumulativeActiveUsers: number; // Total active users up to this date
30
30
+
}
31
31
+
32
32
+
export interface UserEngagementStatsDTO {
33
33
+
// Snapshot data
34
34
+
totalUsers: number;
35
35
+
activeUsers: number; // Created any content
36
36
+
inactiveUsers: number; // Signed in, no content
37
37
+
38
38
+
// Activity breakdown
39
39
+
usersWithCards: number;
40
40
+
usersWithCollections: number;
41
41
+
usersWithConnections: number;
42
42
+
usersWithFollows: number;
43
43
+
usersWithContributions: number; // Added cards to others' collections
44
44
+
45
45
+
// Engagement metrics
46
46
+
activationRate: number; // activeUsers / totalUsers
47
47
+
avgActionsPerActiveUser: number;
48
48
+
49
49
+
// Optional: Time series for trends
50
50
+
dataPoints?: UserEngagementDataPoint[];
51
51
+
}
52
52
+
53
53
+
export interface UserEngagementStatsOptions {
54
54
+
interval?: TimeInterval; // For time series
55
55
+
limit?: number; // For time series
56
56
+
includeTimeSeries?: boolean;
57
57
+
}
58
58
+
23
59
// Future stat types can be added here
24
60
export type UserStatType = 'growth' | 'activity' | 'engagement';
25
61
···
36
72
options: UserGrowthStatsOptions,
37
73
): Promise<UserGrowthStatsDTO>;
38
74
75
75
+
/**
76
76
+
* Get user engagement statistics
77
77
+
* Returns active vs inactive users with content breakdown
78
78
+
*/
79
79
+
getUserEngagementStats(
80
80
+
options: UserEngagementStatsOptions,
81
81
+
): Promise<UserEngagementStatsDTO>;
82
82
+
39
83
// Future methods can be added here:
40
84
// getUserActivityStats(options: UserActivityStatsOptions): Promise<UserActivityStatsDTO>;
41
41
-
// getUserEngagementStats(options: UserEngagementStatsOptions): Promise<UserEngagementStatsDTO>;
42
85
}
···
14
14
async executeImpl(req: Request, res: Response): Promise<any> {
15
15
try {
16
16
// Extract query parameters
17
17
-
const { type, interval, limit } = req.query;
17
17
+
const { type, interval, limit, includeTimeSeries } = req.query;
18
18
19
19
// Validate required parameter
20
20
if (!type) {
···
27
27
return this.fail(res, 'Limit must be a valid number');
28
28
}
29
29
30
30
+
// Parse includeTimeSeries boolean
31
31
+
const includeTimeSeriesFlag = includeTimeSeries === 'true';
32
32
+
30
33
// Execute the use case
31
34
const result = await this.getUserStatsUseCase.execute({
32
35
statType: type as UserStatType,
33
36
interval: interval as TimeInterval | undefined,
34
37
limit: parsedLimit,
38
38
+
includeTimeSeries: includeTimeSeriesFlag,
35
39
});
36
40
37
41
if (result.isErr()) {
···
5
5
UserGrowthStatsDTO,
6
6
UserGrowthStatsOptions,
7
7
UserGrowthDataPoint,
8
8
+
UserEngagementStatsDTO,
9
9
+
UserEngagementStatsOptions,
10
10
+
UserEngagementDataPoint,
8
11
} from '../../domain/IUserStatsRepository';
9
12
import { users } from './schema/user.sql';
13
13
+
import { cards } from '../../../cards/infrastructure/repositories/schema/card.sql';
14
14
+
import { collections } from '../../../cards/infrastructure/repositories/schema/collection.sql';
15
15
+
import { collectionCards } from '../../../cards/infrastructure/repositories/schema/collection.sql';
16
16
+
import { connections } from '../../../cards/infrastructure/repositories/schema/connection.sql';
17
17
+
import { follows } from './schema/follows.sql';
10
18
11
19
export class DrizzleUserStatsRepository implements IUserStatsRepository {
12
20
constructor(private db: PostgresJsDatabase) {}
···
77
85
};
78
86
}
79
87
88
88
+
async getUserEngagementStats(
89
89
+
options: UserEngagementStatsOptions,
90
90
+
): Promise<UserEngagementStatsDTO> {
91
91
+
// Main snapshot query
92
92
+
const snapshotQuery = sql`
93
93
+
WITH user_activity AS (
94
94
+
SELECT
95
95
+
u.id AS user_id,
96
96
+
EXISTS(SELECT 1 FROM ${cards} WHERE author_id = u.id) AS has_cards,
97
97
+
EXISTS(SELECT 1 FROM ${collections} WHERE author_id = u.id) AS has_collections,
98
98
+
EXISTS(SELECT 1 FROM ${connections} WHERE curator_id = u.id) AS has_connections,
99
99
+
EXISTS(SELECT 1 FROM ${follows} WHERE follower_id = u.id) AS has_follows,
100
100
+
EXISTS(SELECT 1 FROM ${collectionCards} WHERE added_by = u.id) AS has_contributions,
101
101
+
(
102
102
+
(SELECT COUNT(*) FROM ${cards} WHERE author_id = u.id) +
103
103
+
(SELECT COUNT(*) FROM ${collections} WHERE author_id = u.id) +
104
104
+
(SELECT COUNT(*) FROM ${connections} WHERE curator_id = u.id) +
105
105
+
(SELECT COUNT(*) FROM ${follows} WHERE follower_id = u.id)
106
106
+
) AS total_actions
107
107
+
FROM ${users} u
108
108
+
),
109
109
+
aggregated AS (
110
110
+
SELECT
111
111
+
COUNT(*)::int AS total_users,
112
112
+
COUNT(*) FILTER (
113
113
+
WHERE has_cards OR has_collections OR has_connections
114
114
+
OR has_follows OR has_contributions
115
115
+
)::int AS active_users,
116
116
+
COUNT(*) FILTER (WHERE has_cards)::int AS users_with_cards,
117
117
+
COUNT(*) FILTER (WHERE has_collections)::int AS users_with_collections,
118
118
+
COUNT(*) FILTER (WHERE has_connections)::int AS users_with_connections,
119
119
+
COUNT(*) FILTER (WHERE has_follows)::int AS users_with_follows,
120
120
+
COUNT(*) FILTER (WHERE has_contributions)::int AS users_with_contributions,
121
121
+
COALESCE(AVG(total_actions) FILTER (WHERE total_actions > 0), 0)::numeric AS avg_actions
122
122
+
FROM user_activity
123
123
+
)
124
124
+
SELECT
125
125
+
total_users,
126
126
+
active_users,
127
127
+
(total_users - active_users) AS inactive_users,
128
128
+
users_with_cards,
129
129
+
users_with_collections,
130
130
+
users_with_connections,
131
131
+
users_with_follows,
132
132
+
users_with_contributions,
133
133
+
CASE
134
134
+
WHEN total_users > 0 THEN (active_users::numeric / total_users::numeric)
135
135
+
ELSE 0
136
136
+
END AS activation_rate,
137
137
+
avg_actions AS avg_actions_per_active_user
138
138
+
FROM aggregated
139
139
+
`;
140
140
+
141
141
+
const result = await this.db.execute(snapshotQuery);
142
142
+
const row = result[0] as any;
143
143
+
144
144
+
// Optional time series data
145
145
+
let dataPoints: UserEngagementDataPoint[] | undefined;
146
146
+
if (options.includeTimeSeries) {
147
147
+
dataPoints = await this.getEngagementTimeSeries(options);
148
148
+
}
149
149
+
150
150
+
return {
151
151
+
totalUsers: row?.total_users || 0,
152
152
+
activeUsers: row?.active_users || 0,
153
153
+
inactiveUsers: row?.inactive_users || 0,
154
154
+
usersWithCards: row?.users_with_cards || 0,
155
155
+
usersWithCollections: row?.users_with_collections || 0,
156
156
+
usersWithConnections: row?.users_with_connections || 0,
157
157
+
usersWithFollows: row?.users_with_follows || 0,
158
158
+
usersWithContributions: row?.users_with_contributions || 0,
159
159
+
activationRate: parseFloat(row?.activation_rate || '0'),
160
160
+
avgActionsPerActiveUser: parseFloat(
161
161
+
row?.avg_actions_per_active_user || '0',
162
162
+
),
163
163
+
dataPoints: dataPoints && dataPoints.length > 0 ? dataPoints : undefined,
164
164
+
};
165
165
+
}
166
166
+
167
167
+
private async getEngagementTimeSeries(
168
168
+
options: UserEngagementStatsOptions,
169
169
+
): Promise<UserEngagementDataPoint[]> {
170
170
+
const { interval = 'day', limit = 30 } = options;
171
171
+
172
172
+
const timeSeriesQuery = sql`
173
173
+
WITH user_first_activity AS (
174
174
+
SELECT
175
175
+
u.id AS user_id,
176
176
+
u.linked_at,
177
177
+
LEAST(
178
178
+
COALESCE((SELECT MIN(created_at) FROM ${cards} WHERE author_id = u.id), '9999-12-31'::timestamp),
179
179
+
COALESCE((SELECT MIN(created_at) FROM ${collections} WHERE author_id = u.id), '9999-12-31'::timestamp),
180
180
+
COALESCE((SELECT MIN(created_at) FROM ${connections} WHERE curator_id = u.id), '9999-12-31'::timestamp),
181
181
+
COALESCE((SELECT MIN(created_at) FROM ${follows} WHERE follower_id = u.id), '9999-12-31'::timestamp),
182
182
+
COALESCE((SELECT MIN(added_at) FROM ${collectionCards} WHERE added_by = u.id), '9999-12-31'::timestamp)
183
183
+
) AS first_action_at
184
184
+
FROM ${users} u
185
185
+
),
186
186
+
period_stats AS (
187
187
+
SELECT
188
188
+
date_trunc(${interval}, first_action_at) AS period,
189
189
+
COUNT(*) FILTER (WHERE first_action_at < '9999-12-31'::timestamp) AS newly_activated
190
190
+
FROM user_first_activity
191
191
+
WHERE first_action_at < '9999-12-31'::timestamp
192
192
+
GROUP BY period
193
193
+
),
194
194
+
cumulative AS (
195
195
+
SELECT
196
196
+
period,
197
197
+
newly_activated,
198
198
+
SUM(newly_activated) OVER (ORDER BY period) AS cumulative_active
199
199
+
FROM period_stats
200
200
+
)
201
201
+
SELECT
202
202
+
period::text AS date,
203
203
+
newly_activated::int AS newly_activated_users,
204
204
+
cumulative_active::int AS cumulative_active_users
205
205
+
FROM cumulative
206
206
+
ORDER BY period DESC
207
207
+
LIMIT ${limit}
208
208
+
`;
209
209
+
210
210
+
const result = await this.db.execute(timeSeriesQuery);
211
211
+
return result
212
212
+
.map((row: any) => ({
213
213
+
date: row.date,
214
214
+
newlyActivatedUsers: row.newly_activated_users || 0,
215
215
+
cumulativeActiveUsers: row.cumulative_active_users || 0,
216
216
+
activeUsers: row.newly_activated_users || 0, // Users activated in this period
217
217
+
}))
218
218
+
.reverse(); // Reverse to get chronological order
219
219
+
}
220
220
+
80
221
// Future stat methods can be added here
81
222
// async getUserActivityStats(options: UserActivityStatsOptions): Promise<UserActivityStatsDTO> { ... }
82
82
-
// async getUserEngagementStats(options: UserEngagementStatsOptions): Promise<UserEngagementStatsDTO> { ... }
83
223
}