src
modules
user
application
domain
infrastructure
···
5
5
UserGrowthStatsDTO,
6
6
UserEngagementStatsDTO,
7
7
DailyActivityStatsDTO,
8
8
+
ContentBreakdownStatsDTO,
8
9
UserStatType,
9
10
TimeInterval,
10
11
} from '../../../domain/IUserStatsRepository';
···
20
21
export type GetUserStatsResult =
21
22
| UserGrowthStatsDTO
22
23
| UserEngagementStatsDTO
23
23
-
| DailyActivityStatsDTO; // Union type for different stat types
24
24
+
| DailyActivityStatsDTO
25
25
+
| ContentBreakdownStatsDTO; // Union type for different stat types
24
26
25
27
export class ValidationError extends Error {
26
28
constructor(message: string) {
···
51
53
52
54
case 'activity':
53
55
return await this.handleActivityStats(query);
56
56
+
57
57
+
case 'breakdown':
58
58
+
return await this.handleBreakdownStats(query);
54
59
55
60
default:
56
61
return err(
···
159
164
return err(
160
165
new Error(
161
166
`Failed to retrieve activity stats: ${error instanceof Error ? error.message : 'Unknown error'}`,
167
167
+
),
168
168
+
);
169
169
+
}
170
170
+
}
171
171
+
172
172
+
private async handleBreakdownStats(
173
173
+
query: GetUserStatsQuery,
174
174
+
): Promise<Result<ContentBreakdownStatsDTO>> {
175
175
+
// Apply defaults
176
176
+
const interval = query.interval || 'day';
177
177
+
const limit = query.limit || 30;
178
178
+
179
179
+
// Validate parameters
180
180
+
if (!['day', 'week', 'month'].includes(interval)) {
181
181
+
return err(new ValidationError(`Invalid interval: ${interval}`));
182
182
+
}
183
183
+
184
184
+
if (limit < 1 || limit > 365) {
185
185
+
return err(new ValidationError('Limit must be between 1 and 365'));
186
186
+
}
187
187
+
188
188
+
try {
189
189
+
const stats = await this.userStatsRepository.getContentBreakdownStats({
190
190
+
interval,
191
191
+
limit,
192
192
+
});
193
193
+
194
194
+
return ok(stats);
195
195
+
} catch (error) {
196
196
+
return err(
197
197
+
new Error(
198
198
+
`Failed to retrieve breakdown stats: ${error instanceof Error ? error.message : 'Unknown error'}`,
162
199
),
163
200
);
164
201
}
···
85
85
limit: number; // Number of intervals to return
86
86
}
87
87
88
88
+
// Content Breakdown Statistics DTOs
89
89
+
90
90
+
export interface ContentBreakdownDataPoint {
91
91
+
date: string; // ISO date string
92
92
+
93
93
+
// URL Cards breakdown
94
94
+
urlCards: {
95
95
+
total: number;
96
96
+
byType: Record<string, number>; // e.g., { "article": 50, "video": 30, "tool": 20 }
97
97
+
};
98
98
+
99
99
+
// Collections breakdown
100
100
+
collections: {
101
101
+
total: number;
102
102
+
byAccessType: Record<string, number>; // e.g., { "OPEN": 80, "CLOSED": 45 }
103
103
+
};
104
104
+
105
105
+
// Connections breakdown
106
106
+
connections: {
107
107
+
total: number;
108
108
+
byType: Record<string, number>; // e.g., { "SUPPORTS": 30, "OPPOSES": 20, null: 15 }
109
109
+
};
110
110
+
}
111
111
+
112
112
+
export interface ContentBreakdownStatsDTO {
113
113
+
dataPoints: ContentBreakdownDataPoint[];
114
114
+
115
115
+
// Current totals (latest snapshot)
116
116
+
currentTotals: {
117
117
+
urlCards: {
118
118
+
total: number;
119
119
+
byType: Record<string, number>;
120
120
+
};
121
121
+
collections: {
122
122
+
total: number;
123
123
+
byAccessType: Record<string, number>;
124
124
+
};
125
125
+
connections: {
126
126
+
total: number;
127
127
+
byType: Record<string, number>;
128
128
+
};
129
129
+
};
130
130
+
131
131
+
periodStart: string;
132
132
+
periodEnd: string;
133
133
+
}
134
134
+
135
135
+
export interface ContentBreakdownStatsOptions {
136
136
+
interval: TimeInterval; // day, week, month
137
137
+
limit: number; // Number of intervals to return
138
138
+
}
139
139
+
88
140
// Future stat types can be added here
89
89
-
export type UserStatType = 'growth' | 'activity' | 'engagement';
141
141
+
export type UserStatType = 'growth' | 'activity' | 'engagement' | 'breakdown';
90
142
91
143
/**
92
144
* Repository interface for user statistics and analytics queries
···
116
168
getDailyActivityStats(
117
169
options: DailyActivityStatsOptions,
118
170
): Promise<DailyActivityStatsDTO>;
171
171
+
172
172
+
/**
173
173
+
* Get content breakdown statistics
174
174
+
* Returns cumulative content totals broken down by subtypes over time
175
175
+
*/
176
176
+
getContentBreakdownStats(
177
177
+
options: ContentBreakdownStatsOptions,
178
178
+
): Promise<ContentBreakdownStatsDTO>;
119
179
}
···
11
11
DailyActivityStatsDTO,
12
12
DailyActivityStatsOptions,
13
13
DailyActivityDataPoint,
14
14
+
ContentBreakdownStatsDTO,
15
15
+
ContentBreakdownStatsOptions,
16
16
+
ContentBreakdownDataPoint,
14
17
} from '../../domain/IUserStatsRepository';
15
18
import { users } from './schema/user.sql';
16
19
import { cards } from '../../../cards/infrastructure/repositories/schema/card.sql';
···
314
317
return {
315
318
dataPoints,
316
319
totals,
320
320
+
periodStart,
321
321
+
periodEnd,
322
322
+
};
323
323
+
}
324
324
+
325
325
+
async getContentBreakdownStats(
326
326
+
options: ContentBreakdownStatsOptions,
327
327
+
): Promise<ContentBreakdownStatsDTO> {
328
328
+
const { interval, limit } = options;
329
329
+
330
330
+
// Query for URL cards breakdown by type over time
331
331
+
const urlCardsQuery = sql`
332
332
+
WITH periods AS (
333
333
+
SELECT DISTINCT date_trunc(${interval}, created_at) AS period
334
334
+
FROM ${cards}
335
335
+
WHERE type = 'URL' AND created_at IS NOT NULL
336
336
+
),
337
337
+
url_card_counts AS (
338
338
+
SELECT
339
339
+
p.period,
340
340
+
COALESCE(c.url_type, 'unspecified') AS url_type,
341
341
+
COUNT(c.id) AS count
342
342
+
FROM periods p
343
343
+
CROSS JOIN LATERAL (
344
344
+
SELECT * FROM ${cards}
345
345
+
WHERE type = 'URL'
346
346
+
AND created_at <= p.period + interval '1 ${interval}'
347
347
+
) c
348
348
+
GROUP BY p.period, c.url_type
349
349
+
),
350
350
+
url_card_aggregated AS (
351
351
+
SELECT
352
352
+
period::text AS date,
353
353
+
jsonb_object_agg(url_type, count) AS by_type,
354
354
+
SUM(count)::int AS total
355
355
+
FROM url_card_counts
356
356
+
GROUP BY period
357
357
+
ORDER BY period DESC
358
358
+
LIMIT ${limit}
359
359
+
)
360
360
+
SELECT * FROM url_card_aggregated
361
361
+
`;
362
362
+
363
363
+
// Query for collections breakdown by access type over time
364
364
+
const collectionsQuery = sql`
365
365
+
WITH periods AS (
366
366
+
SELECT DISTINCT date_trunc(${interval}, created_at) AS period
367
367
+
FROM ${collections}
368
368
+
WHERE created_at IS NOT NULL
369
369
+
),
370
370
+
collection_counts AS (
371
371
+
SELECT
372
372
+
p.period,
373
373
+
col.access_type,
374
374
+
COUNT(col.id) AS count
375
375
+
FROM periods p
376
376
+
CROSS JOIN LATERAL (
377
377
+
SELECT * FROM ${collections}
378
378
+
WHERE created_at <= p.period + interval '1 ${interval}'
379
379
+
) col
380
380
+
GROUP BY p.period, col.access_type
381
381
+
),
382
382
+
collection_aggregated AS (
383
383
+
SELECT
384
384
+
period::text AS date,
385
385
+
jsonb_object_agg(access_type, count) AS by_access_type,
386
386
+
SUM(count)::int AS total
387
387
+
FROM collection_counts
388
388
+
GROUP BY period
389
389
+
ORDER BY period DESC
390
390
+
LIMIT ${limit}
391
391
+
)
392
392
+
SELECT * FROM collection_aggregated
393
393
+
`;
394
394
+
395
395
+
// Query for connections breakdown by type over time
396
396
+
const connectionsQuery = sql`
397
397
+
WITH periods AS (
398
398
+
SELECT DISTINCT date_trunc(${interval}, created_at) AS period
399
399
+
FROM ${connections}
400
400
+
WHERE created_at IS NOT NULL
401
401
+
),
402
402
+
connection_counts AS (
403
403
+
SELECT
404
404
+
p.period,
405
405
+
COALESCE(con.connection_type, 'unspecified') AS connection_type,
406
406
+
COUNT(con.id) AS count
407
407
+
FROM periods p
408
408
+
CROSS JOIN LATERAL (
409
409
+
SELECT * FROM ${connections}
410
410
+
WHERE created_at <= p.period + interval '1 ${interval}'
411
411
+
) con
412
412
+
GROUP BY p.period, con.connection_type
413
413
+
),
414
414
+
connection_aggregated AS (
415
415
+
SELECT
416
416
+
period::text AS date,
417
417
+
jsonb_object_agg(connection_type, count) AS by_type,
418
418
+
SUM(count)::int AS total
419
419
+
FROM connection_counts
420
420
+
GROUP BY period
421
421
+
ORDER BY period DESC
422
422
+
LIMIT ${limit}
423
423
+
)
424
424
+
SELECT * FROM connection_aggregated
425
425
+
`;
426
426
+
427
427
+
// Execute all queries in parallel
428
428
+
const [urlCardsResult, collectionsResult, connectionsResult] =
429
429
+
await Promise.all([
430
430
+
this.db.execute(urlCardsQuery),
431
431
+
this.db.execute(collectionsQuery),
432
432
+
this.db.execute(connectionsQuery),
433
433
+
]);
434
434
+
435
435
+
// Create maps for easy lookup
436
436
+
const urlCardsMap = new Map(
437
437
+
urlCardsResult.map((row: any) => [row.date, row]),
438
438
+
);
439
439
+
const collectionsMap = new Map(
440
440
+
collectionsResult.map((row: any) => [row.date, row]),
441
441
+
);
442
442
+
const connectionsMap = new Map(
443
443
+
connectionsResult.map((row: any) => [row.date, row]),
444
444
+
);
445
445
+
446
446
+
// Get all unique dates and sort them
447
447
+
const allDates = new Set([
448
448
+
...urlCardsMap.keys(),
449
449
+
...collectionsMap.keys(),
450
450
+
...connectionsMap.keys(),
451
451
+
]);
452
452
+
const sortedDates = Array.from(allDates).sort();
453
453
+
454
454
+
// Build data points
455
455
+
const dataPoints: ContentBreakdownDataPoint[] = sortedDates.map((date) => {
456
456
+
const urlCardData = urlCardsMap.get(date);
457
457
+
const collectionData = collectionsMap.get(date);
458
458
+
const connectionData = connectionsMap.get(date);
459
459
+
460
460
+
return {
461
461
+
date,
462
462
+
urlCards: {
463
463
+
total: urlCardData?.total || 0,
464
464
+
byType: urlCardData?.by_type || {},
465
465
+
},
466
466
+
collections: {
467
467
+
total: collectionData?.total || 0,
468
468
+
byAccessType: collectionData?.by_access_type || {},
469
469
+
},
470
470
+
connections: {
471
471
+
total: connectionData?.total || 0,
472
472
+
byType: connectionData?.by_type || {},
473
473
+
},
474
474
+
};
475
475
+
});
476
476
+
477
477
+
// Get current totals (the most recent data point or fetch separately if needed)
478
478
+
const currentUrlCards = urlCardsResult[0] as any;
479
479
+
const currentCollections = collectionsResult[0] as any;
480
480
+
const currentConnections = connectionsResult[0] as any;
481
481
+
482
482
+
const currentTotals = {
483
483
+
urlCards: {
484
484
+
total: currentUrlCards?.total || 0,
485
485
+
byType: currentUrlCards?.by_type || {},
486
486
+
},
487
487
+
collections: {
488
488
+
total: currentCollections?.total || 0,
489
489
+
byAccessType: currentCollections?.by_access_type || {},
490
490
+
},
491
491
+
connections: {
492
492
+
total: currentConnections?.total || 0,
493
493
+
byType: currentConnections?.by_type || {},
494
494
+
},
495
495
+
};
496
496
+
497
497
+
// Determine period range
498
498
+
const periodStart = sortedDates[0] || new Date().toISOString();
499
499
+
const periodEnd =
500
500
+
sortedDates[sortedDates.length - 1] || new Date().toISOString();
501
501
+
502
502
+
return {
503
503
+
dataPoints,
504
504
+
currentTotals,
317
505
periodStart,
318
506
periodEnd,
319
507
};