diff --git a/src/modules/cards/tests/test-utils/createTestSchema.ts b/src/modules/cards/tests/test-utils/createTestSchema.ts index ba615dca..fbf8611c 100644 --- a/src/modules/cards/tests/test-utils/createTestSchema.ts +++ b/src/modules/cards/tests/test-utils/createTestSchema.ts @@ -7,6 +7,14 @@ export async function createTestSchema(db: PostgresJsDatabase) { // Create tables in dependency order using raw SQL with proper column names const tableCreationQueries = [ + // Users table (no dependencies) + sql`CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + handle TEXT, + linked_at TIMESTAMP WITH TIME ZONE NOT NULL, + last_login_at TIMESTAMP WITH TIME ZONE NOT NULL + )`, + // Published records table (no dependencies) sql`CREATE TABLE IF NOT EXISTS published_records ( id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), diff --git a/src/modules/user/application/useCases/queries/GetUserStatsUseCase.ts b/src/modules/user/application/useCases/queries/GetUserStatsUseCase.ts index 72044e8a..29af5729 100644 --- a/src/modules/user/application/useCases/queries/GetUserStatsUseCase.ts +++ b/src/modules/user/application/useCases/queries/GetUserStatsUseCase.ts @@ -3,6 +3,7 @@ import { UseCase } from 'src/shared/core/UseCase'; import { IUserStatsRepository, UserGrowthStatsDTO, + UserEngagementStatsDTO, UserStatType, TimeInterval, } from '../../../domain/IUserStatsRepository'; @@ -11,10 +12,11 @@ export interface GetUserStatsQuery { statType: UserStatType; interval?: TimeInterval; limit?: number; + includeTimeSeries?: boolean; // For engagement stats // Future parameters can be added here for other stat types } -export type GetUserStatsResult = UserGrowthStatsDTO; // Will be a union type as we add more stat types +export type GetUserStatsResult = UserGrowthStatsDTO | UserEngagementStatsDTO; // Union type for different stat types export class ValidationError extends Error { constructor(message: string) { @@ -40,11 +42,12 @@ export class GetUserStatsUseCase case 'growth': return await this.handleGrowthStats(query); + case 'engagement': + return await this.handleEngagementStats(query); + // Future stat types can be added here // case 'activity': // return await this.handleActivityStats(query); - // case 'engagement': - // return await this.handleEngagementStats(query); default: return err( @@ -92,7 +95,40 @@ export class GetUserStatsUseCase } } + private async handleEngagementStats( + query: GetUserStatsQuery, + ): Promise> { + // Apply defaults + const interval = query.interval || 'day'; + const limit = query.limit || 30; + const includeTimeSeries = query.includeTimeSeries || false; + + // Validate parameters + if (interval && !['day', 'week', 'month'].includes(interval)) { + return err(new ValidationError(`Invalid interval: ${interval}`)); + } + + if (limit < 1 || limit > 365) { + return err(new ValidationError('Limit must be between 1 and 365')); + } + + try { + const stats = await this.userStatsRepository.getUserEngagementStats({ + interval, + limit, + includeTimeSeries, + }); + + return ok(stats); + } catch (error) { + return err( + new Error( + `Failed to retrieve engagement stats: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } + // Future handler methods can be added here // private async handleActivityStats(query: GetUserStatsQuery): Promise> { ... } - // private async handleEngagementStats(query: GetUserStatsQuery): Promise> { ... } } diff --git a/src/modules/user/domain/IUserStatsRepository.ts b/src/modules/user/domain/IUserStatsRepository.ts index 1094c32d..c7e8be14 100644 --- a/src/modules/user/domain/IUserStatsRepository.ts +++ b/src/modules/user/domain/IUserStatsRepository.ts @@ -20,6 +20,42 @@ export interface UserGrowthStatsOptions { limit: number; // Number of intervals to return } +// User Engagement Statistics DTOs + +export interface UserEngagementDataPoint { + date: string; + activeUsers: number; // Users who created content in this period + newlyActivatedUsers: number; // Previously inactive users who became active + cumulativeActiveUsers: number; // Total active users up to this date +} + +export interface UserEngagementStatsDTO { + // Snapshot data + totalUsers: number; + activeUsers: number; // Created any content + inactiveUsers: number; // Signed in, no content + + // Activity breakdown + usersWithCards: number; + usersWithCollections: number; + usersWithConnections: number; + usersWithFollows: number; + usersWithContributions: number; // Added cards to others' collections + + // Engagement metrics + activationRate: number; // activeUsers / totalUsers + avgActionsPerActiveUser: number; + + // Optional: Time series for trends + dataPoints?: UserEngagementDataPoint[]; +} + +export interface UserEngagementStatsOptions { + interval?: TimeInterval; // For time series + limit?: number; // For time series + includeTimeSeries?: boolean; +} + // Future stat types can be added here export type UserStatType = 'growth' | 'activity' | 'engagement'; @@ -36,7 +72,14 @@ export interface IUserStatsRepository { options: UserGrowthStatsOptions, ): Promise; + /** + * Get user engagement statistics + * Returns active vs inactive users with content breakdown + */ + getUserEngagementStats( + options: UserEngagementStatsOptions, + ): Promise; + // Future methods can be added here: // getUserActivityStats(options: UserActivityStatsOptions): Promise; - // getUserEngagementStats(options: UserEngagementStatsOptions): Promise; } diff --git a/src/modules/user/infrastructure/http/controllers/GetUserStatsController.ts b/src/modules/user/infrastructure/http/controllers/GetUserStatsController.ts index 9b40ab6a..977356dd 100644 --- a/src/modules/user/infrastructure/http/controllers/GetUserStatsController.ts +++ b/src/modules/user/infrastructure/http/controllers/GetUserStatsController.ts @@ -14,7 +14,7 @@ export class GetUserStatsController extends Controller { async executeImpl(req: Request, res: Response): Promise { try { // Extract query parameters - const { type, interval, limit } = req.query; + const { type, interval, limit, includeTimeSeries } = req.query; // Validate required parameter if (!type) { @@ -27,11 +27,15 @@ export class GetUserStatsController extends Controller { return this.fail(res, 'Limit must be a valid number'); } + // Parse includeTimeSeries boolean + const includeTimeSeriesFlag = includeTimeSeries === 'true'; + // Execute the use case const result = await this.getUserStatsUseCase.execute({ statType: type as UserStatType, interval: interval as TimeInterval | undefined, limit: parsedLimit, + includeTimeSeries: includeTimeSeriesFlag, }); if (result.isErr()) { diff --git a/src/modules/user/infrastructure/repositories/DrizzleUserStatsRepository.ts b/src/modules/user/infrastructure/repositories/DrizzleUserStatsRepository.ts index 4dc84bd8..e82933f1 100644 --- a/src/modules/user/infrastructure/repositories/DrizzleUserStatsRepository.ts +++ b/src/modules/user/infrastructure/repositories/DrizzleUserStatsRepository.ts @@ -5,8 +5,16 @@ import { UserGrowthStatsDTO, UserGrowthStatsOptions, UserGrowthDataPoint, + UserEngagementStatsDTO, + UserEngagementStatsOptions, + UserEngagementDataPoint, } from '../../domain/IUserStatsRepository'; import { users } from './schema/user.sql'; +import { cards } from '../../../cards/infrastructure/repositories/schema/card.sql'; +import { collections } from '../../../cards/infrastructure/repositories/schema/collection.sql'; +import { collectionCards } from '../../../cards/infrastructure/repositories/schema/collection.sql'; +import { connections } from '../../../cards/infrastructure/repositories/schema/connection.sql'; +import { follows } from './schema/follows.sql'; export class DrizzleUserStatsRepository implements IUserStatsRepository { constructor(private db: PostgresJsDatabase) {} @@ -77,7 +85,139 @@ export class DrizzleUserStatsRepository implements IUserStatsRepository { }; } + async getUserEngagementStats( + options: UserEngagementStatsOptions, + ): Promise { + // Main snapshot query + const snapshotQuery = sql` + WITH user_activity AS ( + SELECT + u.id AS user_id, + EXISTS(SELECT 1 FROM ${cards} WHERE author_id = u.id) AS has_cards, + EXISTS(SELECT 1 FROM ${collections} WHERE author_id = u.id) AS has_collections, + EXISTS(SELECT 1 FROM ${connections} WHERE curator_id = u.id) AS has_connections, + EXISTS(SELECT 1 FROM ${follows} WHERE follower_id = u.id) AS has_follows, + EXISTS(SELECT 1 FROM ${collectionCards} WHERE added_by = u.id) AS has_contributions, + ( + (SELECT COUNT(*) FROM ${cards} WHERE author_id = u.id) + + (SELECT COUNT(*) FROM ${collections} WHERE author_id = u.id) + + (SELECT COUNT(*) FROM ${connections} WHERE curator_id = u.id) + + (SELECT COUNT(*) FROM ${follows} WHERE follower_id = u.id) + ) AS total_actions + FROM ${users} u + ), + aggregated AS ( + SELECT + COUNT(*)::int AS total_users, + COUNT(*) FILTER ( + WHERE has_cards OR has_collections OR has_connections + OR has_follows OR has_contributions + )::int AS active_users, + COUNT(*) FILTER (WHERE has_cards)::int AS users_with_cards, + COUNT(*) FILTER (WHERE has_collections)::int AS users_with_collections, + COUNT(*) FILTER (WHERE has_connections)::int AS users_with_connections, + COUNT(*) FILTER (WHERE has_follows)::int AS users_with_follows, + COUNT(*) FILTER (WHERE has_contributions)::int AS users_with_contributions, + COALESCE(AVG(total_actions) FILTER (WHERE total_actions > 0), 0)::numeric AS avg_actions + FROM user_activity + ) + SELECT + total_users, + active_users, + (total_users - active_users) AS inactive_users, + users_with_cards, + users_with_collections, + users_with_connections, + users_with_follows, + users_with_contributions, + CASE + WHEN total_users > 0 THEN (active_users::numeric / total_users::numeric) + ELSE 0 + END AS activation_rate, + avg_actions AS avg_actions_per_active_user + FROM aggregated + `; + + const result = await this.db.execute(snapshotQuery); + const row = result[0] as any; + + // Optional time series data + let dataPoints: UserEngagementDataPoint[] | undefined; + if (options.includeTimeSeries) { + dataPoints = await this.getEngagementTimeSeries(options); + } + + return { + totalUsers: row?.total_users || 0, + activeUsers: row?.active_users || 0, + inactiveUsers: row?.inactive_users || 0, + usersWithCards: row?.users_with_cards || 0, + usersWithCollections: row?.users_with_collections || 0, + usersWithConnections: row?.users_with_connections || 0, + usersWithFollows: row?.users_with_follows || 0, + usersWithContributions: row?.users_with_contributions || 0, + activationRate: parseFloat(row?.activation_rate || '0'), + avgActionsPerActiveUser: parseFloat( + row?.avg_actions_per_active_user || '0', + ), + dataPoints: dataPoints && dataPoints.length > 0 ? dataPoints : undefined, + }; + } + + private async getEngagementTimeSeries( + options: UserEngagementStatsOptions, + ): Promise { + const { interval = 'day', limit = 30 } = options; + + const timeSeriesQuery = sql` + WITH user_first_activity AS ( + SELECT + u.id AS user_id, + u.linked_at, + LEAST( + COALESCE((SELECT MIN(created_at) FROM ${cards} WHERE author_id = u.id), '9999-12-31'::timestamp), + COALESCE((SELECT MIN(created_at) FROM ${collections} WHERE author_id = u.id), '9999-12-31'::timestamp), + COALESCE((SELECT MIN(created_at) FROM ${connections} WHERE curator_id = u.id), '9999-12-31'::timestamp), + COALESCE((SELECT MIN(created_at) FROM ${follows} WHERE follower_id = u.id), '9999-12-31'::timestamp), + COALESCE((SELECT MIN(added_at) FROM ${collectionCards} WHERE added_by = u.id), '9999-12-31'::timestamp) + ) AS first_action_at + FROM ${users} u + ), + period_stats AS ( + SELECT + date_trunc(${interval}, first_action_at) AS period, + COUNT(*) FILTER (WHERE first_action_at < '9999-12-31'::timestamp) AS newly_activated + FROM user_first_activity + WHERE first_action_at < '9999-12-31'::timestamp + GROUP BY period + ), + cumulative AS ( + SELECT + period, + newly_activated, + SUM(newly_activated) OVER (ORDER BY period) AS cumulative_active + FROM period_stats + ) + SELECT + period::text AS date, + newly_activated::int AS newly_activated_users, + cumulative_active::int AS cumulative_active_users + FROM cumulative + ORDER BY period DESC + LIMIT ${limit} + `; + + const result = await this.db.execute(timeSeriesQuery); + return result + .map((row: any) => ({ + date: row.date, + newlyActivatedUsers: row.newly_activated_users || 0, + cumulativeActiveUsers: row.cumulative_active_users || 0, + activeUsers: row.newly_activated_users || 0, // Users activated in this period + })) + .reverse(); // Reverse to get chronological order + } + // Future stat methods can be added here // async getUserActivityStats(options: UserActivityStatsOptions): Promise { ... } - // async getUserEngagementStats(options: UserEngagementStatsOptions): Promise { ... } }