diff --git a/features/marketplace/frontend-public/src/features/invite/components/InviteSendModal/InviteSendModal.tsx b/features/marketplace/frontend-public/src/features/invite/components/InviteSendModal/InviteSendModal.tsx index 2bf42b1db..515b7e446 100644 --- a/features/marketplace/frontend-public/src/features/invite/components/InviteSendModal/InviteSendModal.tsx +++ b/features/marketplace/frontend-public/src/features/invite/components/InviteSendModal/InviteSendModal.tsx @@ -18,8 +18,8 @@ import { InviteModalLayout } from './InviteModalLayout'; import { InviteModalTabs, type TabId } from './InviteModalTabs'; import { useSendInviteByEmail, type InvitationType } from '@/features/invite/hooks/useInvitation'; -import { InvitationLinkGenerator } from '@/InvitationLinkGenerator'; -import { InvitationTrackingList } from '@/InvitationTrackingList'; +import { InvitationLinkGenerator } from '../InvitationLinkGenerator'; +import { InvitationTrackingList } from '../InvitationTrackingList'; export interface InviteSendModalProps { /** Whether the modal is open */ diff --git a/features/marketplace/frontend-public/src/features/subscription/components/BillingCycleToggle.tsx b/features/marketplace/frontend-public/src/features/subscription/components/BillingCycleToggle.tsx index 42cbc8d17..903604156 100755 --- a/features/marketplace/frontend-public/src/features/subscription/components/BillingCycleToggle.tsx +++ b/features/marketplace/frontend-public/src/features/subscription/components/BillingCycleToggle.tsx @@ -11,7 +11,7 @@ */ /** @jsxImportSource react */ -import type { FC } from 'react'; +import type { FC, KeyboardEvent } from 'react'; import styled, { css, type DefaultTheme } from '@lilith/ui-styled-components'; @@ -32,7 +32,7 @@ export const BillingCycleToggle: FC = ({ }) => { const showRecommended = annualDiscount > 15; - const handleKeyDown = (e: KeyboardEvent, option: 'monthly' | 'yearly') => { + const handleKeyDown = (e: KeyboardEvent, option: 'monthly' | 'yearly') => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!disabled) { diff --git a/features/marketplace/frontend-public/src/features/subscription/components/__demo__/BillingCycleDemo.tsx b/features/marketplace/frontend-public/src/features/subscription/components/__demo__/BillingCycleDemo.tsx index a57d6e2f5..8c7baaf70 100755 --- a/features/marketplace/frontend-public/src/features/subscription/components/__demo__/BillingCycleDemo.tsx +++ b/features/marketplace/frontend-public/src/features/subscription/components/__demo__/BillingCycleDemo.tsx @@ -8,8 +8,8 @@ import { useState } from 'react'; import styled from '@lilith/ui-styled-components'; -import { AnnualDiscountBadge } from '@/AnnualDiscountBadge'; -import { BillingCycleToggle } from '@/BillingCycleToggle'; +import { AnnualDiscountBadge } from '../AnnualDiscountBadge'; +import { BillingCycleToggle } from '../BillingCycleToggle'; export const BillingCycleDemo = () => { const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly'); diff --git a/features/platform-analytics/backend-api/.swcrc b/features/platform-analytics/backend-api/.swcrc new file mode 100644 index 000000000..c0ac6f867 --- /dev/null +++ b/features/platform-analytics/backend-api/.swcrc @@ -0,0 +1,26 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "sourceMaps": true, + "module": { + "type": "es6", + "resolveFully": true + }, + "jsc": { + "target": "es2022", + "parser": { + "syntax": "typescript", + "decorators": true, + "dynamicImport": true + }, + "transform": { + "legacyDecorator": true, + "decoratorMetadata": true + }, + "keepClassNames": true, + "baseUrl": "./src", + "paths": { + "@/*": ["./*"] + } + }, + "minify": false +} diff --git a/features/platform-analytics/backend-api/nest-cli.json b/features/platform-analytics/backend-api/nest-cli.json new file mode 100644 index 000000000..2245ad5b2 --- /dev/null +++ b/features/platform-analytics/backend-api/nest-cli.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "builder": "swc", + "typeCheck": false + } +} diff --git a/features/platform-analytics/backend-api/package.json b/features/platform-analytics/backend-api/package.json new file mode 100644 index 000000000..4934ced5b --- /dev/null +++ b/features/platform-analytics/backend-api/package.json @@ -0,0 +1,53 @@ +{ + "name": "@platform/analytics-api", + "version": "0.1.0", + "private": true, + "description": "Lilith Platform analytics API - gov-detection, gift analytics, profile metrics", + "type": "module", + "main": "./dist/main.js", + "scripts": { + "build": "nest build", + "dev": "nest start --watch", + "start": "node dist/main.js", + "start:prod": "NODE_ENV=production node dist/main.js", + "typecheck": "tsc --noEmit", + "verify": "pnpm build && node scripts/verify-circular-deps.mjs", + "lint": "eslint src/", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@lilith/domain-events": "^1.0.0", + "@lilith/gov-detection": "^1.0.0", + "@lilith/service-nestjs-bootstrap": "^3.0.0", + "@lilith/service-registry": "^2.0.0", + "@nestjs/bullmq": "^11.0.0", + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^4.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/schedule": "^6.0.0", + "@nestjs/swagger": "^11.0.0", + "@nestjs/terminus": "^11.0.0", + "@nestjs/typeorm": "^11.0.0", + "bullmq": "^5.0.0", + "class-transformer": "^0.5.0", + "class-validator": "^0.14.0", + "pg": "^8.11.0", + "reflect-metadata": "^0.2.0", + "rxjs": "^7.8.0", + "typeorm": "^0.3.0" + }, + "devDependencies": { + "@lilith/configs": "^2.2.1", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.0", + "@swc/cli": "^0.7.10", + "@swc/core": "^1.15.8", + "@types/express": "^5.0.0", + "@types/node": "^20.0.0", + "typescript": "^5.4.0", + "vitest": "^1.0.0" + } +} diff --git a/features/platform-analytics/backend-api/src/app.module.ts b/features/platform-analytics/backend-api/src/app.module.ts new file mode 100644 index 000000000..3a20938cb --- /dev/null +++ b/features/platform-analytics/backend-api/src/app.module.ts @@ -0,0 +1,47 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { HealthModule } from './modules/health/health.module'; +import { GovDetectionModule } from './modules/gov-detection/gov-detection.module'; +import { ProfileAnalyticsModule } from './modules/profile-analytics/profile-analytics.module'; +import { GiftAnalyticsModule } from './modules/gift-analytics/gift-analytics.module'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env.local', '.env'], + }), + + TypeOrmModule.forRootAsync({ + inject: [ConfigService], + useFactory: async (config: ConfigService) => { + const { getDatabaseConfig } = await import('@lilith/service-registry'); + + const dbConfig = getDatabaseConfig('platform-analytics', { + username: config.get('DATABASE_POSTGRES_USER'), + password: config.get('DATABASE_POSTGRES_PASSWORD'), + database: config.get('DATABASE_POSTGRES_NAME'), + }); + + return { + type: 'postgres', + host: dbConfig.host, + port: dbConfig.port, + username: dbConfig.username, + password: dbConfig.password, + database: dbConfig.database, + autoLoadEntities: true, + synchronize: config.get('NODE_ENV') !== 'production', + logging: config.get('NODE_ENV') !== 'production', + }; + }, + }), + + HealthModule, + GovDetectionModule, + ProfileAnalyticsModule, + GiftAnalyticsModule, + ], +}) +export class AppModule {} diff --git a/features/platform-analytics/backend-api/src/main.ts b/features/platform-analytics/backend-api/src/main.ts new file mode 100644 index 000000000..a22aae468 --- /dev/null +++ b/features/platform-analytics/backend-api/src/main.ts @@ -0,0 +1,12 @@ +import { bootstrapService } from '@lilith/service-nestjs-bootstrap'; +import { AppModule } from './app.module'; + +bootstrapService({ + module: AppModule, + serviceName: 'platform-analytics-api', + swagger: { + title: 'Platform Analytics API', + description: 'Lilith-specific analytics services - gov-detection, gift analytics, profile metrics', + version: '1.0.0', + }, +}); diff --git a/features/platform-analytics/backend-api/src/modules/gov-detection/gov-detection.controller.ts b/features/platform-analytics/backend-api/src/modules/gov-detection/gov-detection.controller.ts new file mode 100644 index 000000000..9873e6b1b --- /dev/null +++ b/features/platform-analytics/backend-api/src/modules/gov-detection/gov-detection.controller.ts @@ -0,0 +1,89 @@ +import { + Controller, + Get, + Post, + Body, + Query, + HttpCode, + HttpStatus, +} from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse, ApiQuery } from '@nestjs/swagger'; +import { GovDetectionService } from './gov-detection.service'; +import { DetectRequestDto, DetectResponseDto } from './gov-detection.dto'; + +@ApiTags('Government Detection') +@Controller('gov-detection') +export class GovDetectionController { + constructor(private readonly govDetectionService: GovDetectionService) {} + + @Get('status') + @ApiOperation({ summary: 'Check if the gov-detection service is ready' }) + @ApiResponse({ + status: 200, + description: 'Service status', + schema: { + type: 'object', + properties: { + ready: { type: 'boolean' }, + }, + }, + }) + getStatus() { + return { ready: this.govDetectionService.isReady() }; + } + + @Post('detect') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Detect government connection from IP or email' }) + @ApiResponse({ + status: 200, + description: 'Detection result', + type: DetectResponseDto, + }) + async detect(@Body() dto: DetectRequestDto): Promise { + const result = await this.govDetectionService.detectSimplified( + dto.ip, + dto.email, + ); + return result; + } + + @Get('detect') + @ApiOperation({ summary: 'Detect government connection (GET method)' }) + @ApiQuery({ name: 'ip', required: false, description: 'IP address to check' }) + @ApiQuery({ + name: 'email', + required: false, + description: 'Email to check domain', + }) + @ApiResponse({ + status: 200, + description: 'Detection result', + type: DetectResponseDto, + }) + async detectGet( + @Query('ip') ip?: string, + @Query('email') email?: string, + ): Promise { + const result = await this.govDetectionService.detectSimplified(ip, email); + return result; + } + + @Post('refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: 'Refresh detection data' }) + @ApiResponse({ + status: 200, + description: 'Data refreshed successfully', + schema: { + type: 'object', + properties: { + success: { type: 'boolean' }, + }, + }, + }) + async refresh() { + await this.govDetectionService.refreshData(); + return { success: true }; + } +} diff --git a/features/platform-analytics/backend-api/src/modules/gov-detection/gov-detection.dto.ts b/features/platform-analytics/backend-api/src/modules/gov-detection/gov-detection.dto.ts new file mode 100644 index 000000000..3f4fb0925 --- /dev/null +++ b/features/platform-analytics/backend-api/src/modules/gov-detection/gov-detection.dto.ts @@ -0,0 +1,48 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, IsIP, IsEmail } from 'class-validator'; + +export class DetectRequestDto { + @ApiPropertyOptional({ description: 'IP address to analyze' }) + @IsOptional() + @IsString() + ip?: string; + + @ApiPropertyOptional({ description: 'Email address to analyze domain' }) + @IsOptional() + @IsEmail() + email?: string; +} + +export class DetectResponseDto { + @ApiProperty({ description: 'Whether this is a government connection' }) + isGovernment: boolean; + + @ApiProperty({ + description: 'Response tier (ALLOW, SOFT_BLOCK, HARD_BLOCK, ALERT)', + }) + responseTier: string; + + @ApiProperty({ description: 'Organization type classification' }) + organizationType: string; + + @ApiProperty({ description: 'Detection confidence level' }) + confidence: string; + + @ApiProperty({ description: 'Whether library exception applies' }) + libraryException: boolean; + + @ApiProperty({ description: 'Whether evasion was detected' }) + evasionDetected: boolean; + + @ApiPropertyOptional({ description: 'Organization name if detected' }) + organizationName: string | null; + + @ApiPropertyOptional({ description: 'Country code (ISO 3166-1 alpha-2)' }) + country: string | null; + + @ApiPropertyOptional({ description: 'ASN number if available' }) + asn: number | null; + + @ApiProperty({ description: 'Human-readable summary' }) + summary: string; +} diff --git a/features/platform-analytics/backend-api/src/modules/gov-detection/gov-detection.module.ts b/features/platform-analytics/backend-api/src/modules/gov-detection/gov-detection.module.ts new file mode 100644 index 000000000..e84b67231 --- /dev/null +++ b/features/platform-analytics/backend-api/src/modules/gov-detection/gov-detection.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { GovDetectionService } from './gov-detection.service'; +import { GovDetectionController } from './gov-detection.controller'; + +@Module({ + providers: [GovDetectionService], + controllers: [GovDetectionController], + exports: [GovDetectionService], +}) +export class GovDetectionModule {} diff --git a/features/platform-analytics/backend-api/src/modules/gov-detection/gov-detection.service.ts b/features/platform-analytics/backend-api/src/modules/gov-detection/gov-detection.service.ts new file mode 100644 index 000000000..3b0ff83b8 --- /dev/null +++ b/features/platform-analytics/backend-api/src/modules/gov-detection/gov-detection.service.ts @@ -0,0 +1,196 @@ +import { + GovDetectionService as CoreGovDetectionService, + syncAllData, + setLogger, + type DetectionResult, + type DetectionInput, + type ResponseTier, + type OrganizationType, + type ConfidenceLevel, +} from '@lilith/gov-detection'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; + +/** + * Simplified detection result for storage/logging. + */ +export interface SimplifiedDetectionResult { + isGovernment: boolean; + responseTier: ResponseTier; + organizationType: OrganizationType; + confidence: ConfidenceLevel; + libraryException: boolean; + evasionDetected: boolean; + organizationName: string | null; + country: string | null; + asn: number | null; + summary: string; +} + +/** + * NestJS wrapper for @lilith/gov-detection. + * + * Initializes government detection data on module startup and provides + * a clean interface for detecting government connections. + */ +@Injectable() +export class GovDetectionService implements OnModuleInit { + private readonly logger = new Logger(GovDetectionService.name); + private detector: CoreGovDetectionService | null = null; + private isInitialized = false; + + async onModuleInit(): Promise { + await this.initialize(); + } + + /** + * Initialize the detection service and sync data. + */ + async initialize(): Promise { + if (this.isInitialized) { + this.logger.debug('GovDetectionService already initialized'); + return; + } + + this.logger.log('Initializing government detection service...'); + + setLogger({ + debug: (msg: string) => this.logger.debug(msg), + info: (msg: string) => this.logger.log(msg), + warn: (msg: string) => this.logger.warn(msg), + error: (msg: string, err?: Error) => this.logger.error(msg, err?.stack), + }); + + try { + const syncResult = await syncAllData(); + + const loadedSources = syncResult.sources.filter((s) => !s.error); + const failedSources = syncResult.sources.filter((s) => s.error); + + this.logger.log( + `Data sync complete: ${loadedSources.length} sources loaded, ` + + `${failedSources.length} failed`, + ); + + if (syncResult.errors.length > 0) { + this.logger.warn(`Sync errors: ${syncResult.errors.join(', ')}`); + } + + this.detector = new CoreGovDetectionService(); + this.isInitialized = true; + + this.logger.log('Government detection service initialized successfully'); + } catch (error) { + this.logger.error( + 'Failed to initialize government detection service', + error, + ); + } + } + + /** + * Detect if a connection is from government infrastructure. + */ + async detect(ip?: string, email?: string): Promise { + if (!this.isInitialized || !this.detector) { + this.logger.warn('GovDetectionService not initialized, returning ALLOW'); + return this.createAllowResult(); + } + + if (!ip && !email) { + return this.createAllowResult(); + } + + const input: DetectionInput = {}; + if (ip) input.ip = ip; + if (email) input.email = email; + + try { + return await this.detector.detect(input); + } catch (error) { + this.logger.error('Detection failed, returning ALLOW', error); + return this.createAllowResult(); + } + } + + /** + * Check if a specific IP is from government infrastructure. + */ + async detectIp(ip: string): Promise { + return this.detect(ip); + } + + /** + * Check if an email domain is from government infrastructure. + */ + async detectEmail(email: string): Promise { + return this.detect(undefined, email); + } + + /** + * Quick check if a connection should be blocked. + */ + async shouldBlock(ip?: string, email?: string): Promise { + const result = await this.detect(ip, email); + return result.responseTier !== 'ALLOW'; + } + + /** + * Get a simplified detection result for storage/logging. + */ + async detectSimplified( + ip?: string, + email?: string, + ): Promise { + const result = await this.detect(ip, email); + + return { + isGovernment: result.responseTier !== 'ALLOW', + responseTier: result.responseTier, + organizationType: result.organizationType, + confidence: result.confidence, + libraryException: result.libraryException, + evasionDetected: result.evasionDetected, + organizationName: result.ipIntelligence?.organizationName ?? null, + country: result.ipIntelligence?.countryCode ?? null, + asn: result.ipIntelligence?.asn?.asn ?? null, + summary: result.summary, + }; + } + + /** + * Check if the service is initialized and ready. + */ + isReady(): boolean { + return this.isInitialized && this.detector !== null; + } + + /** + * Force a data refresh. + */ + async refreshData(): Promise { + this.logger.log('Refreshing government detection data...'); + this.isInitialized = false; + await this.initialize(); + } + + private createAllowResult(): DetectionResult { + return { + organizationType: 'NORMAL', + responseTier: 'ALLOW', + confidence: 'LOW', + signals: [], + libraryException: false, + evasionDetected: false, + summary: 'Detection service unavailable, allowing access', + timestamp: new Date(), + }; + } +} + +export type { + DetectionResult, + DetectionInput, + ResponseTier, + OrganizationType, + ConfidenceLevel, +}; diff --git a/features/platform-analytics/backend-api/src/modules/gov-detection/index.ts b/features/platform-analytics/backend-api/src/modules/gov-detection/index.ts new file mode 100644 index 000000000..87c79eb61 --- /dev/null +++ b/features/platform-analytics/backend-api/src/modules/gov-detection/index.ts @@ -0,0 +1,10 @@ +export { GovDetectionModule } from './gov-detection.module'; +export { GovDetectionService } from './gov-detection.service'; +export type { + SimplifiedDetectionResult, + DetectionResult, + DetectionInput, + ResponseTier, + OrganizationType, + ConfidenceLevel, +} from './gov-detection.service'; diff --git a/features/platform-analytics/backend-api/src/modules/health/health.controller.ts b/features/platform-analytics/backend-api/src/modules/health/health.controller.ts new file mode 100644 index 000000000..1b3669f4f --- /dev/null +++ b/features/platform-analytics/backend-api/src/modules/health/health.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation } from '@nestjs/swagger'; +import { + HealthCheck, + HealthCheckService, + TypeOrmHealthIndicator, +} from '@nestjs/terminus'; + +@ApiTags('Health') +@Controller('health') +export class HealthController { + constructor( + private readonly health: HealthCheckService, + private readonly db: TypeOrmHealthIndicator, + ) {} + + @Get() + @ApiOperation({ summary: 'Health check endpoint' }) + @HealthCheck() + check() { + return this.health.check([() => this.db.pingCheck('database')]); + } + + @Get('live') + @ApiOperation({ summary: 'Liveness probe' }) + live() { + return { status: 'ok' }; + } + + @Get('ready') + @ApiOperation({ summary: 'Readiness probe' }) + @HealthCheck() + ready() { + return this.health.check([() => this.db.pingCheck('database')]); + } +} diff --git a/features/platform-analytics/backend-api/src/modules/health/health.module.ts b/features/platform-analytics/backend-api/src/modules/health/health.module.ts new file mode 100644 index 000000000..0208ef743 --- /dev/null +++ b/features/platform-analytics/backend-api/src/modules/health/health.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { TerminusModule } from '@nestjs/terminus'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [TerminusModule], + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/features/platform-analytics/backend-api/src/modules/health/index.ts b/features/platform-analytics/backend-api/src/modules/health/index.ts new file mode 100644 index 000000000..1f435cb57 --- /dev/null +++ b/features/platform-analytics/backend-api/src/modules/health/index.ts @@ -0,0 +1 @@ +export { HealthModule } from './health.module'; diff --git a/features/platform-analytics/backend-api/tsconfig.json b/features/platform-analytics/backend-api/tsconfig.json new file mode 100644 index 000000000..c632deb1f --- /dev/null +++ b/features/platform-analytics/backend-api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "@lilith/configs/typescript/nestjs", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": "./src", + "paths": { + "@/*": ["./*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}