This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

active user stats

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