src
modules
user
application
domain
infrastructure
···
4
4
IUserStatsRepository,
5
5
UserGrowthStatsDTO,
6
6
UserEngagementStatsDTO,
7
7
+
DailyActivityStatsDTO,
7
8
UserStatType,
8
9
TimeInterval,
9
10
} from '../../../domain/IUserStatsRepository';
···
16
17
// Future parameters can be added here for other stat types
17
18
}
18
19
19
19
-
export type GetUserStatsResult = UserGrowthStatsDTO | UserEngagementStatsDTO; // Union type for different stat types
20
20
+
export type GetUserStatsResult =
21
21
+
| UserGrowthStatsDTO
22
22
+
| UserEngagementStatsDTO
23
23
+
| DailyActivityStatsDTO; // Union type for different stat types
20
24
21
25
export class ValidationError extends Error {
22
26
constructor(message: string) {
···
45
49
case 'engagement':
46
50
return await this.handleEngagementStats(query);
47
51
48
48
-
// Future stat types can be added here
49
49
-
// case 'activity':
50
50
-
// return await this.handleActivityStats(query);
52
52
+
case 'activity':
53
53
+
return await this.handleActivityStats(query);
51
54
52
55
default:
53
56
return err(
···
129
132
}
130
133
}
131
134
132
132
-
// Future handler methods can be added here
133
133
-
// private async handleActivityStats(query: GetUserStatsQuery): Promise<Result<UserActivityStatsDTO>> { ... }
135
135
+
private async handleActivityStats(
136
136
+
query: GetUserStatsQuery,
137
137
+
): Promise<Result<DailyActivityStatsDTO>> {
138
138
+
// Apply defaults
139
139
+
const interval = query.interval || 'day';
140
140
+
const limit = query.limit || 30;
141
141
+
142
142
+
// Validate parameters
143
143
+
if (!['day', 'week', 'month'].includes(interval)) {
144
144
+
return err(new ValidationError(`Invalid interval: ${interval}`));
145
145
+
}
146
146
+
147
147
+
if (limit < 1 || limit > 365) {
148
148
+
return err(new ValidationError('Limit must be between 1 and 365'));
149
149
+
}
150
150
+
151
151
+
try {
152
152
+
const stats = await this.userStatsRepository.getDailyActivityStats({
153
153
+
interval,
154
154
+
limit,
155
155
+
});
156
156
+
157
157
+
return ok(stats);
158
158
+
} catch (error) {
159
159
+
return err(
160
160
+
new Error(
161
161
+
`Failed to retrieve activity stats: ${error instanceof Error ? error.message : 'Unknown error'}`,
162
162
+
),
163
163
+
);
164
164
+
}
165
165
+
}
134
166
}
···
56
56
includeTimeSeries?: boolean;
57
57
}
58
58
59
59
+
// Daily Activity Statistics DTOs
60
60
+
61
61
+
export interface DailyActivityDataPoint {
62
62
+
date: string; // ISO date string
63
63
+
cardsCreated: number;
64
64
+
collectionsCreated: number;
65
65
+
connectionsCreated: number;
66
66
+
followsCreated: number;
67
67
+
totalActions: number; // Sum of all above
68
68
+
}
69
69
+
70
70
+
export interface DailyActivityStatsDTO {
71
71
+
dataPoints: DailyActivityDataPoint[];
72
72
+
totals: {
73
73
+
cardsCreated: number;
74
74
+
collectionsCreated: number;
75
75
+
connectionsCreated: number;
76
76
+
followsCreated: number;
77
77
+
totalActions: number;
78
78
+
};
79
79
+
periodStart: string;
80
80
+
periodEnd: string;
81
81
+
}
82
82
+
83
83
+
export interface DailyActivityStatsOptions {
84
84
+
interval: TimeInterval; // day, week, month
85
85
+
limit: number; // Number of intervals to return
86
86
+
}
87
87
+
59
88
// Future stat types can be added here
60
89
export type UserStatType = 'growth' | 'activity' | 'engagement';
61
90
···
80
109
options: UserEngagementStatsOptions,
81
110
): Promise<UserEngagementStatsDTO>;
82
111
83
83
-
// Future methods can be added here:
84
84
-
// getUserActivityStats(options: UserActivityStatsOptions): Promise<UserActivityStatsDTO>;
112
112
+
/**
113
113
+
* Get daily activity statistics
114
114
+
* Returns content creation volume over time periods
115
115
+
*/
116
116
+
getDailyActivityStats(
117
117
+
options: DailyActivityStatsOptions,
118
118
+
): Promise<DailyActivityStatsDTO>;
85
119
}
···
8
8
UserEngagementStatsDTO,
9
9
UserEngagementStatsOptions,
10
10
UserEngagementDataPoint,
11
11
+
DailyActivityStatsDTO,
12
12
+
DailyActivityStatsOptions,
13
13
+
DailyActivityDataPoint,
11
14
} from '../../domain/IUserStatsRepository';
12
15
import { users } from './schema/user.sql';
13
16
import { cards } from '../../../cards/infrastructure/repositories/schema/card.sql';
···
93
96
WITH user_activity AS (
94
97
SELECT
95
98
u.id AS user_id,
96
96
-
EXISTS(SELECT 1 FROM ${cards} WHERE author_id = u.id) AS has_cards,
99
99
+
EXISTS(SELECT 1 FROM ${cards} WHERE author_id = u.id AND type = 'URL') AS has_cards,
97
100
EXISTS(SELECT 1 FROM ${collections} WHERE author_id = u.id) AS has_collections,
98
101
EXISTS(SELECT 1 FROM ${connections} WHERE curator_id = u.id) AS has_connections,
99
102
EXISTS(SELECT 1 FROM ${follows} WHERE follower_id = u.id) AS has_follows,
100
103
EXISTS(SELECT 1 FROM ${collectionCards} WHERE added_by = u.id) AS has_contributions,
101
104
(
102
102
-
(SELECT COUNT(*) FROM ${cards} WHERE author_id = u.id) +
105
105
+
(SELECT COUNT(*) FROM ${cards} WHERE author_id = u.id AND type = 'URL') +
103
106
(SELECT COUNT(*) FROM ${collections} WHERE author_id = u.id) +
104
107
(SELECT COUNT(*) FROM ${connections} WHERE curator_id = u.id) +
105
108
(SELECT COUNT(*) FROM ${follows} WHERE follower_id = u.id)
···
175
178
u.id AS user_id,
176
179
u.linked_at,
177
180
LEAST(
178
178
-
COALESCE((SELECT MIN(created_at) FROM ${cards} WHERE author_id = u.id), '9999-12-31'::timestamp),
181
181
+
COALESCE((SELECT MIN(created_at) FROM ${cards} WHERE author_id = u.id AND type = 'URL'), '9999-12-31'::timestamp),
179
182
COALESCE((SELECT MIN(created_at) FROM ${collections} WHERE author_id = u.id), '9999-12-31'::timestamp),
180
183
COALESCE((SELECT MIN(created_at) FROM ${connections} WHERE curator_id = u.id), '9999-12-31'::timestamp),
181
184
COALESCE((SELECT MIN(created_at) FROM ${follows} WHERE follower_id = u.id), '9999-12-31'::timestamp),
···
218
221
.reverse(); // Reverse to get chronological order
219
222
}
220
223
221
221
-
// Future stat methods can be added here
222
222
-
// async getUserActivityStats(options: UserActivityStatsOptions): Promise<UserActivityStatsDTO> { ... }
224
224
+
async getDailyActivityStats(
225
225
+
options: DailyActivityStatsOptions,
226
226
+
): Promise<DailyActivityStatsDTO> {
227
227
+
const { interval, limit } = options;
228
228
+
229
229
+
// Query for content creation over time
230
230
+
const activityQuery = sql`
231
231
+
WITH date_periods AS (
232
232
+
SELECT DISTINCT date_trunc(${interval}, created_at) AS period
233
233
+
FROM (
234
234
+
SELECT created_at FROM ${cards} WHERE type = 'URL'
235
235
+
UNION ALL
236
236
+
SELECT created_at FROM ${collections}
237
237
+
UNION ALL
238
238
+
SELECT created_at FROM ${connections}
239
239
+
UNION ALL
240
240
+
SELECT created_at FROM ${follows}
241
241
+
) all_dates
242
242
+
WHERE created_at IS NOT NULL
243
243
+
),
244
244
+
activity_counts AS (
245
245
+
SELECT
246
246
+
dp.period,
247
247
+
COALESCE(COUNT(DISTINCT c.id), 0)::int AS cards_created,
248
248
+
COALESCE(COUNT(DISTINCT col.id), 0)::int AS collections_created,
249
249
+
COALESCE(COUNT(DISTINCT con.id), 0)::int AS connections_created,
250
250
+
COALESCE(COUNT(DISTINCT f.follower_id || '-' || f.target_id || '-' || f.target_type), 0)::int AS follows_created
251
251
+
FROM date_periods dp
252
252
+
LEFT JOIN ${cards} c ON date_trunc(${interval}, c.created_at) = dp.period AND c.type = 'URL'
253
253
+
LEFT JOIN ${collections} col ON date_trunc(${interval}, col.created_at) = dp.period
254
254
+
LEFT JOIN ${connections} con ON date_trunc(${interval}, con.created_at) = dp.period
255
255
+
LEFT JOIN ${follows} f ON date_trunc(${interval}, f.created_at) = dp.period
256
256
+
GROUP BY dp.period
257
257
+
)
258
258
+
SELECT
259
259
+
period::text AS date,
260
260
+
cards_created,
261
261
+
collections_created,
262
262
+
connections_created,
263
263
+
follows_created,
264
264
+
(cards_created + collections_created + connections_created + follows_created) AS total_actions
265
265
+
FROM activity_counts
266
266
+
ORDER BY period DESC
267
267
+
LIMIT ${limit}
268
268
+
`;
269
269
+
270
270
+
const result = await this.db.execute(activityQuery);
271
271
+
272
272
+
// Calculate totals
273
273
+
let totals = {
274
274
+
cardsCreated: 0,
275
275
+
collectionsCreated: 0,
276
276
+
connectionsCreated: 0,
277
277
+
followsCreated: 0,
278
278
+
totalActions: 0,
279
279
+
};
280
280
+
281
281
+
const dataPoints: DailyActivityDataPoint[] = result
282
282
+
.map((row: any) => {
283
283
+
const point = {
284
284
+
date: row.date,
285
285
+
cardsCreated: row.cards_created || 0,
286
286
+
collectionsCreated: row.collections_created || 0,
287
287
+
connectionsCreated: row.connections_created || 0,
288
288
+
followsCreated: row.follows_created || 0,
289
289
+
totalActions: row.total_actions || 0,
290
290
+
};
291
291
+
292
292
+
// Accumulate totals
293
293
+
totals.cardsCreated += point.cardsCreated;
294
294
+
totals.collectionsCreated += point.collectionsCreated;
295
295
+
totals.connectionsCreated += point.connectionsCreated;
296
296
+
totals.followsCreated += point.followsCreated;
297
297
+
totals.totalActions += point.totalActions;
298
298
+
299
299
+
return point;
300
300
+
})
301
301
+
.reverse(); // Reverse to get chronological order
302
302
+
303
303
+
// Determine the period range
304
304
+
const periodStart =
305
305
+
dataPoints.length > 0 && dataPoints[0]
306
306
+
? dataPoints[0].date
307
307
+
: new Date().toISOString();
308
308
+
const lastDataPoint =
309
309
+
dataPoints.length > 0 ? dataPoints[dataPoints.length - 1] : undefined;
310
310
+
const periodEnd = lastDataPoint
311
311
+
? lastDataPoint.date
312
312
+
: new Date().toISOString();
313
313
+
314
314
+
return {
315
315
+
dataPoints,
316
316
+
totals,
317
317
+
periodStart,
318
318
+
periodEnd,
319
319
+
};
320
320
+
}
223
321
}