diff --git a/src/modules/user/application/useCases/queries/GetUserStatsUseCase.ts b/src/modules/user/application/useCases/queries/GetUserStatsUseCase.ts new file mode 100644 index 00000000..72044e8a --- /dev/null +++ b/src/modules/user/application/useCases/queries/GetUserStatsUseCase.ts @@ -0,0 +1,98 @@ +import { err, ok, Result } from 'src/shared/core/Result'; +import { UseCase } from 'src/shared/core/UseCase'; +import { + IUserStatsRepository, + UserGrowthStatsDTO, + UserStatType, + TimeInterval, +} from '../../../domain/IUserStatsRepository'; + +export interface GetUserStatsQuery { + statType: UserStatType; + interval?: TimeInterval; + limit?: number; + // 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 class ValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'ValidationError'; + } +} + +export class GetUserStatsUseCase + implements UseCase> +{ + constructor(private userStatsRepository: IUserStatsRepository) {} + + async execute(query: GetUserStatsQuery): Promise> { + try { + // Validate query parameters + if (!query.statType) { + return err(new ValidationError('Stat type is required')); + } + + // Route to appropriate repository method based on stat type + switch (query.statType) { + case 'growth': + return await this.handleGrowthStats(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( + new ValidationError(`Invalid stat type: ${query.statType}`), + ); + } + } catch (error) { + return err( + new Error( + `Failed to retrieve user stats: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } + + private async handleGrowthStats( + query: GetUserStatsQuery, + ): Promise> { + // Apply defaults + const interval = query.interval || 'day'; + const limit = query.limit || 30; + + // Validate parameters + if (!['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.getUserGrowthStats({ + interval, + limit, + }); + + return ok(stats); + } catch (error) { + return err( + new Error( + `Failed to retrieve growth 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 new file mode 100644 index 00000000..1094c32d --- /dev/null +++ b/src/modules/user/domain/IUserStatsRepository.ts @@ -0,0 +1,42 @@ +// DTOs for User Statistics + +export interface UserGrowthDataPoint { + date: string; // ISO date string for chart x-axis + totalUsers: number; // Cumulative total users up to this date + newUsers: number; // New users added in this period +} + +export interface UserGrowthStatsDTO { + dataPoints: UserGrowthDataPoint[]; + currentTotal: number; + periodStart: string; + periodEnd: string; +} + +export type TimeInterval = 'day' | 'week' | 'month'; + +export interface UserGrowthStatsOptions { + interval: TimeInterval; + limit: number; // Number of intervals to return +} + +// Future stat types can be added here +export type UserStatType = 'growth' | 'activity' | 'engagement'; + +/** + * Repository interface for user statistics and analytics queries + * This is a read-only query repository focused on aggregations and time-series data + */ +export interface IUserStatsRepository { + /** + * Get user growth statistics over time + * Returns cumulative user counts with period-over-period growth + */ + getUserGrowthStats( + options: UserGrowthStatsOptions, + ): 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 new file mode 100644 index 00000000..9b40ab6a --- /dev/null +++ b/src/modules/user/infrastructure/http/controllers/GetUserStatsController.ts @@ -0,0 +1,46 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Request, Response } from 'express'; +import { GetUserStatsUseCase } from '../../../application/useCases/queries/GetUserStatsUseCase'; +import { + UserStatType, + TimeInterval, +} from '../../../domain/IUserStatsRepository'; + +export class GetUserStatsController extends Controller { + constructor(private getUserStatsUseCase: GetUserStatsUseCase) { + super(); + } + + async executeImpl(req: Request, res: Response): Promise { + try { + // Extract query parameters + const { type, interval, limit } = req.query; + + // Validate required parameter + if (!type) { + return this.fail(res, 'Stat type is required'); + } + + // Parse and validate limit if provided + const parsedLimit = limit ? parseInt(limit as string, 10) : undefined; + if (parsedLimit !== undefined && isNaN(parsedLimit)) { + return this.fail(res, 'Limit must be a valid number'); + } + + // Execute the use case + const result = await this.getUserStatsUseCase.execute({ + statType: type as UserStatType, + interval: interval as TimeInterval | undefined, + limit: parsedLimit, + }); + + if (result.isErr()) { + return this.fail(res, result.error); + } + + return this.ok(res, result.value); + } catch (error: any) { + return this.fail(res, error); + } + } +} diff --git a/src/modules/user/infrastructure/http/middleware/StatsApiKeyMiddleware.ts b/src/modules/user/infrastructure/http/middleware/StatsApiKeyMiddleware.ts new file mode 100644 index 00000000..2e2d4aba --- /dev/null +++ b/src/modules/user/infrastructure/http/middleware/StatsApiKeyMiddleware.ts @@ -0,0 +1,40 @@ +import { Request, Response, NextFunction } from 'express'; + +export class StatsApiKeyMiddleware { + constructor(private statsApiKey: string | undefined) {} + + ensureAuthenticated() { + return (req: Request, res: Response, next: NextFunction) => { + // Check if STATS_API_KEY is configured + if (!this.statsApiKey) { + return res.status(503).json({ + error: 'Stats API is not configured', + message: 'STATS_API_KEY environment variable is not set', + }); + } + + // Extract bearer token from Authorization header + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + error: 'Unauthorized', + message: + 'Missing or invalid Authorization header. Expected format: Bearer ', + }); + } + + const token = authHeader.substring(7); // Remove 'Bearer ' prefix + + // Validate the API key + if (token !== this.statsApiKey) { + return res.status(401).json({ + error: 'Unauthorized', + message: 'Invalid API key', + }); + } + + // Authentication successful, proceed to the next middleware/handler + next(); + }; + } +} diff --git a/src/modules/user/infrastructure/http/routes/statsRoutes.ts b/src/modules/user/infrastructure/http/routes/statsRoutes.ts new file mode 100644 index 00000000..51b593d9 --- /dev/null +++ b/src/modules/user/infrastructure/http/routes/statsRoutes.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; +import { GetUserStatsController } from '../controllers/GetUserStatsController'; +import { StatsApiKeyMiddleware } from '../middleware/StatsApiKeyMiddleware'; + +export const createStatsRoutes = ( + router: Router, + statsApiKeyMiddleware: StatsApiKeyMiddleware, + getUserStatsController: GetUserStatsController, +) => { + // All stats routes require API key authentication + router.use(statsApiKeyMiddleware.ensureAuthenticated()); + + // Get statistics based on type query parameter + // Example: GET /api/stats?type=growth&interval=day&limit=30 + router.get('/', (req, res) => getUserStatsController.execute(req, res)); + + // Future stats routes can be added here + // router.get('/users/activity', ...) + // router.get('/users/engagement', ...) + // router.get('/content/metrics', ...) + + return router; +}; diff --git a/src/modules/user/infrastructure/index.ts b/src/modules/user/infrastructure/index.ts index 9931f401..a838187d 100644 --- a/src/modules/user/infrastructure/index.ts +++ b/src/modules/user/infrastructure/index.ts @@ -1,6 +1,7 @@ // Repositories export * from './repositories/DrizzleUserRepository'; export * from './repositories/DrizzleFollowsRepository'; +export * from './repositories/DrizzleUserStatsRepository'; // Services export * from '../../atproto/infrastructure/services/AtProtoOAuthProcessor'; diff --git a/src/modules/user/infrastructure/repositories/DrizzleUserStatsRepository.ts b/src/modules/user/infrastructure/repositories/DrizzleUserStatsRepository.ts new file mode 100644 index 00000000..4dc84bd8 --- /dev/null +++ b/src/modules/user/infrastructure/repositories/DrizzleUserStatsRepository.ts @@ -0,0 +1,83 @@ +import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { sql } from 'drizzle-orm'; +import { + IUserStatsRepository, + UserGrowthStatsDTO, + UserGrowthStatsOptions, + UserGrowthDataPoint, +} from '../../domain/IUserStatsRepository'; +import { users } from './schema/user.sql'; + +export class DrizzleUserStatsRepository implements IUserStatsRepository { + constructor(private db: PostgresJsDatabase) {} + + async getUserGrowthStats( + options: UserGrowthStatsOptions, + ): Promise { + const { interval, limit } = options; + + // Get the time-series data with cumulative counts + const query = sql` + WITH date_series AS ( + SELECT + date_trunc(${interval}, linked_at) AS period, + COUNT(*) AS new_users_in_period + FROM ${users} + GROUP BY period + ), + cumulative_counts AS ( + SELECT + period, + new_users_in_period, + SUM(new_users_in_period) OVER (ORDER BY period) AS total_users + FROM date_series + ) + SELECT + period::text AS date, + total_users::int AS total_users, + new_users_in_period::int AS new_users + FROM cumulative_counts + ORDER BY period DESC + LIMIT ${limit} + `; + + const result = await this.db.execute(query); + + // Get current total user count + const totalCountResult = await this.db.execute(sql` + SELECT COUNT(*)::int AS count FROM ${users} + `); + const currentTotal = (totalCountResult[0] as any)?.count || 0; + + // Transform the results into the DTO format + const dataPoints: UserGrowthDataPoint[] = result + .map((row: any) => ({ + date: row.date, + totalUsers: row.total_users, + newUsers: row.new_users, + })) + .reverse(); // Reverse to get chronological order (oldest to newest) + + // Determine the period range + const periodStart = + dataPoints.length > 0 && dataPoints[0] + ? dataPoints[0].date + : new Date().toISOString(); + const lastDataPoint = + dataPoints.length > 0 ? dataPoints[dataPoints.length - 1] : undefined; + const periodEnd = lastDataPoint + ? lastDataPoint.date + : new Date().toISOString(); + + return { + dataPoints, + currentTotal, + periodStart, + periodEnd, + }; + } + + // Future stat methods can be added here + // async getUserActivityStats(options: UserActivityStatsOptions): Promise { ... } + // async getUserEngagementStats(options: UserEngagementStatsOptions): Promise { ... } +} diff --git a/src/shared/infrastructure/config/EnvironmentConfigService.ts b/src/shared/infrastructure/config/EnvironmentConfigService.ts index 336d41a8..899f8ac5 100644 --- a/src/shared/infrastructure/config/EnvironmentConfigService.ts +++ b/src/shared/infrastructure/config/EnvironmentConfigService.ts @@ -71,6 +71,9 @@ export interface EnvironmentConfig { dsn: string; environment: string; }; + stats: { + apiKey: string | undefined; + }; } export class EnvironmentConfigService { @@ -179,6 +182,9 @@ export class EnvironmentConfigService { dsn: process.env.SENTRY_DSN || '', environment: environment, }, + stats: { + apiKey: process.env.STATS_API_KEY, + }, }; this.applyEnvironmentSpecificConfig(); @@ -259,6 +265,10 @@ export class EnvironmentConfigService { return this.config.sentry; } + public getStatsConfig() { + return this.config.stats; + } + public getRuntimeConfig() { return this.config.runtime; } diff --git a/src/shared/infrastructure/http/app.ts b/src/shared/infrastructure/http/app.ts index 3d495b57..b0c73d69 100644 --- a/src/shared/infrastructure/http/app.ts +++ b/src/shared/infrastructure/http/app.ts @@ -4,6 +4,7 @@ import cookieParser from 'cookie-parser'; import { Router } from 'express'; import * as Sentry from '@sentry/node'; import { createUserRoutes } from '../../../modules/user/infrastructure/http/routes/userRoutes'; +import { createStatsRoutes } from '../../../modules/user/infrastructure/http/routes/statsRoutes'; import { createAtprotoRoutes } from '../../../modules/atproto/infrastructure/atprotoRoutes'; import { createCardsModuleRoutes } from '../../../modules/cards/infrastructure/http/routes'; import { createConnectionRoutes } from '../../../modules/cards/infrastructure/http/routes/connectionRoutes'; @@ -185,6 +186,13 @@ export const createExpressApp = ( const testRouter = Router(); createTestRoutes(testRouter); + const statsRouter = Router(); + createStatsRoutes( + statsRouter, + services.statsApiKeyMiddleware, + controllers.getUserStatsController, + ); + // DID Web endpoint app.get('/.well-known/did.json', (req, res) => { return res.json({ @@ -216,6 +224,7 @@ export const createExpressApp = ( app.use('/api/search', searchRouter); app.use('/api/notifications', notificationRouter); app.use('/api/test', testRouter); + app.use('/api/stats', statsRouter); // Sentry error handler - must be after all routes and before other error middleware Sentry.setupExpressErrorHandler(app); diff --git a/src/shared/infrastructure/http/factories/ControllerFactory.ts b/src/shared/infrastructure/http/factories/ControllerFactory.ts index be678c3e..be679bb8 100644 --- a/src/shared/infrastructure/http/factories/ControllerFactory.ts +++ b/src/shared/infrastructure/http/factories/ControllerFactory.ts @@ -53,6 +53,7 @@ import { GetFollowingCollectionsController } from '../../../../modules/user/infr import { GetFollowingCountController } from '../../../../modules/user/infrastructure/http/controllers/GetFollowingCountController'; import { GetFollowersCountController } from '../../../../modules/user/infrastructure/http/controllers/GetFollowersCountController'; import { GetFollowingCollectionsCountController } from '../../../../modules/user/infrastructure/http/controllers/GetFollowingCollectionsCountController'; +import { GetUserStatsController } from '../../../../modules/user/infrastructure/http/controllers/GetUserStatsController'; import { GetCollectionFollowersController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionFollowersController'; import { GetCollectionFollowersCountController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionFollowersCountController'; import { GetCollectionContributorsController } from '../../../../modules/cards/infrastructure/http/controllers/GetCollectionContributorsController'; @@ -87,6 +88,7 @@ export interface Controllers { getFollowingCountController: GetFollowingCountController; getFollowersCountController: GetFollowersCountController; getFollowingCollectionsCountController: GetFollowingCollectionsCountController; + getUserStatsController: GetUserStatsController; // Card controllers addUrlToLibraryController: AddUrlToLibraryController; addCardToLibraryController: AddCardToLibraryController; @@ -210,6 +212,9 @@ export class ControllerFactory { new GetFollowingCollectionsCountController( useCases.getFollowingCollectionsCountUseCase, ), + getUserStatsController: new GetUserStatsController( + useCases.getUserStatsUseCase, + ), // Card controllers addUrlToLibraryController: new AddUrlToLibraryController( diff --git a/src/shared/infrastructure/http/factories/RepositoryFactory.ts b/src/shared/infrastructure/http/factories/RepositoryFactory.ts index 12ae3335..6a0879e5 100644 --- a/src/shared/infrastructure/http/factories/RepositoryFactory.ts +++ b/src/shared/infrastructure/http/factories/RepositoryFactory.ts @@ -51,12 +51,15 @@ import { InMemorySyncStatusRepository } from '../../../../modules/sync/tests/inf import { IFollowsRepository } from '../../../../modules/user/domain/repositories/IFollowsRepository'; import { DrizzleFollowsRepository } from '../../../../modules/user/infrastructure/repositories/DrizzleFollowsRepository'; import { InMemoryFollowsRepository } from '../../../../modules/user/tests/infrastructure/InMemoryFollowsRepository'; +import { IUserStatsRepository } from '../../../../modules/user/domain/IUserStatsRepository'; +import { DrizzleUserStatsRepository } from '../../../../modules/user/infrastructure/repositories/DrizzleUserStatsRepository'; import { IGraphQueryRepository } from '../../../../modules/cards/domain/IGraphQueryRepository'; import { DrizzleGraphQueryRepository } from '../../../../modules/cards/infrastructure/repositories/DrizzleGraphQueryRepository'; import { InMemoryGraphQueryRepository } from '../../../../modules/cards/tests/utils/InMemoryGraphQueryRepository'; export interface Repositories { userRepository: IUserRepository; + userStatsRepository: IUserStatsRepository; tokenRepository: ITokenRepository; cardRepository: ICardRepository; cardQueryRepository: ICardQueryRepository; @@ -119,8 +122,16 @@ export class RepositoryFactory { const oauthStateStore = InMemoryStateStore.getInstance(); const oauthSessionStore = InMemorySessionStore.getInstance(); + // For mock repos, we'll use a basic implementation of stats repository + // Since it's read-only stats, we can create a simple instance + const db = DatabaseFactory.createConnection( + configService.getDatabaseConfig(), + ); + const userStatsRepository = new DrizzleUserStatsRepository(db); + return { userRepository, + userStatsRepository, tokenRepository, cardRepository, cardQueryRepository, @@ -149,6 +160,7 @@ export class RepositoryFactory { return { userRepository: new DrizzleUserRepository(db), + userStatsRepository: new DrizzleUserStatsRepository(db), tokenRepository: new DrizzleTokenRepository(db), cardRepository: new DrizzleCardRepository(db), cardQueryRepository: new DrizzleCardQueryRepository(db), diff --git a/src/shared/infrastructure/http/factories/ServiceFactory.ts b/src/shared/infrastructure/http/factories/ServiceFactory.ts index fbd67657..6d915c1e 100644 --- a/src/shared/infrastructure/http/factories/ServiceFactory.ts +++ b/src/shared/infrastructure/http/factories/ServiceFactory.ts @@ -24,6 +24,7 @@ import { FakeFollowPublisher } from '../../../../modules/atproto/infrastructure/ import { CardLibraryService } from '../../../../modules/cards/domain/services/CardLibraryService'; import { CardCollectionService } from '../../../../modules/cards/domain/services/CardCollectionService'; import { AuthMiddleware } from '../middleware/AuthMiddleware'; +import { StatsApiKeyMiddleware } from '../../../../modules/user/infrastructure/http/middleware/StatsApiKeyMiddleware'; import { Repositories } from './RepositoryFactory'; import { NodeOAuthClient } from '@atproto/oauth-client-node'; import { AppPasswordSessionService } from 'src/modules/atproto/infrastructure/services/AppPasswordSessionService'; @@ -107,6 +108,7 @@ export interface WebAppServices extends SharedServices { appPasswordProcessor: IAppPasswordProcessor; cardPublisher: ICardPublisher; authMiddleware: AuthMiddleware; + statsApiKeyMiddleware: StatsApiKeyMiddleware; } // Worker specific services (includes subscribers) @@ -170,12 +172,17 @@ export class ServiceFactory { sharedServices.cookieService, ); + const statsApiKeyMiddleware = new StatsApiKeyMiddleware( + configService.getStatsConfig().apiKey, + ); + return { ...sharedServices, oauthProcessor, appPasswordProcessor, cardPublisher, authMiddleware, + statsApiKeyMiddleware, }; } diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index bbc1042e..d763a77e 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -70,6 +70,7 @@ import { GetFollowingCountUseCase } from '../../../../modules/user/application/u import { GetFollowersCountUseCase } from '../../../../modules/user/application/useCases/queries/GetFollowersCountUseCase'; import { GetFollowingCollectionsCountUseCase } from '../../../../modules/user/application/useCases/queries/GetFollowingCollectionsCountUseCase'; import { GetCollectionFollowersCountUseCase } from '../../../../modules/user/application/useCases/queries/GetCollectionFollowersCountUseCase'; +import { GetUserStatsUseCase } from '../../../../modules/user/application/useCases/queries/GetUserStatsUseCase'; import { GetCollectionContributorsUseCase } from '../../../../modules/cards/application/useCases/queries/GetCollectionContributorsUseCase'; import { SearchUrlsUseCase } from '../../../../modules/cards/application/useCases/queries/SearchUrlsUseCase'; import { GetGraphDataUseCase } from '../../../../modules/cards/application/useCases/queries/GetGraphDataUseCase'; @@ -118,6 +119,7 @@ export interface UseCases { getFollowingCollectionsCountUseCase: GetFollowingCollectionsCountUseCase; getCollectionFollowersCountUseCase: GetCollectionFollowersCountUseCase; getCollectionContributorsUseCase: GetCollectionContributorsUseCase; + getUserStatsUseCase: GetUserStatsUseCase; // Card use cases addUrlToLibraryUseCase: AddUrlToLibraryUseCase; addCardToLibraryUseCase: AddCardToLibraryUseCase; @@ -278,6 +280,9 @@ export class UseCaseFactory { services.profileService, repositories.collectionRepository, ), + getUserStatsUseCase: new GetUserStatsUseCase( + repositories.userStatsRepository, + ), // Card use cases addUrlToLibraryUseCase: new AddUrlToLibraryUseCase(