From f29ef4aa3a823d9a794f52371d5ba020455951a0 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 23 Jan 2026 20:39:04 +0100 Subject: [PATCH] feat(infra): add Telegram Stats Bot for ManaCore analytics Adds a NestJS service that delivers Umami analytics via Telegram: - Telegram commands: /start, /stats, /today, /week, /realtime, /users - Scheduled reports: Daily at 9:00, Weekly on Monday at 9:00 - Umami API integration with token management - User statistics from auth database - Docker + CI/CD pipeline integration Bot: @stats_mana_bot Co-Authored-By: Claude Opus 4.5 --- .github/workflows/cd-staging.yml | 1 + .github/workflows/ci.yml | 41 ++++ services/telegram-stats-bot/.env.example | 15 ++ services/telegram-stats-bot/CLAUDE.md | 150 ++++++++++++ services/telegram-stats-bot/Dockerfile | 58 +++++ services/telegram-stats-bot/nest-cli.json | 8 + services/telegram-stats-bot/package.json | 38 +++ .../src/analytics/analytics.module.ts | 10 + .../src/analytics/analytics.service.ts | 122 ++++++++++ .../src/analytics/formatters.ts | 229 ++++++++++++++++++ services/telegram-stats-bot/src/app.module.ts | 36 +++ .../telegram-stats-bot/src/bot/bot.module.ts | 12 + .../telegram-stats-bot/src/bot/bot.service.ts | 40 +++ .../telegram-stats-bot/src/bot/bot.update.ts | 79 ++++++ .../src/config/configuration.ts | 71 ++++++ .../src/health.controller.ts | 13 + services/telegram-stats-bot/src/main.ts | 18 ++ .../src/scheduler/report.scheduler.ts | 66 +++++ .../src/scheduler/scheduler.module.ts | 10 + .../src/umami/umami.module.ts | 8 + .../src/umami/umami.service.ts | 135 +++++++++++ .../src/users/users.module.ts | 8 + .../src/users/users.service.ts | 77 ++++++ services/telegram-stats-bot/tsconfig.json | 28 +++ 24 files changed, 1273 insertions(+) create mode 100644 services/telegram-stats-bot/.env.example create mode 100644 services/telegram-stats-bot/CLAUDE.md create mode 100644 services/telegram-stats-bot/Dockerfile create mode 100644 services/telegram-stats-bot/nest-cli.json create mode 100644 services/telegram-stats-bot/package.json create mode 100644 services/telegram-stats-bot/src/analytics/analytics.module.ts create mode 100644 services/telegram-stats-bot/src/analytics/analytics.service.ts create mode 100644 services/telegram-stats-bot/src/analytics/formatters.ts create mode 100644 services/telegram-stats-bot/src/app.module.ts create mode 100644 services/telegram-stats-bot/src/bot/bot.module.ts create mode 100644 services/telegram-stats-bot/src/bot/bot.service.ts create mode 100644 services/telegram-stats-bot/src/bot/bot.update.ts create mode 100644 services/telegram-stats-bot/src/config/configuration.ts create mode 100644 services/telegram-stats-bot/src/health.controller.ts create mode 100644 services/telegram-stats-bot/src/main.ts create mode 100644 services/telegram-stats-bot/src/scheduler/report.scheduler.ts create mode 100644 services/telegram-stats-bot/src/scheduler/scheduler.module.ts create mode 100644 services/telegram-stats-bot/src/umami/umami.module.ts create mode 100644 services/telegram-stats-bot/src/umami/umami.service.ts create mode 100644 services/telegram-stats-bot/src/users/users.module.ts create mode 100644 services/telegram-stats-bot/src/users/users.service.ts create mode 100644 services/telegram-stats-bot/tsconfig.json diff --git a/.github/workflows/cd-staging.yml b/.github/workflows/cd-staging.yml index 4af222aed..6c8fcb97b 100644 --- a/.github/workflows/cd-staging.yml +++ b/.github/workflows/cd-staging.yml @@ -31,6 +31,7 @@ on: - calendar-web - clock-backend - clock-web + - telegram-stats-bot workflow_call: permissions: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c220f1cd6..29da30e69 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,6 +61,7 @@ jobs: clock-web: ${{ steps.changes.outputs.clock-web }} contacts-backend: ${{ steps.changes.outputs.contacts-backend }} contacts-web: ${{ steps.changes.outputs.contacts-web }} + telegram-stats-bot: ${{ steps.changes.outputs.telegram-stats-bot }} any-changes: ${{ steps.changes.outputs.any-changes }} steps: - name: Checkout code @@ -86,6 +87,7 @@ jobs: echo "clock-web=true" >> $GITHUB_OUTPUT echo "contacts-backend=true" >> $GITHUB_OUTPUT echo "contacts-web=true" >> $GITHUB_OUTPUT + echo "telegram-stats-bot=true" >> $GITHUB_OUTPUT echo "any-changes=true" >> $GITHUB_OUTPUT exit 0 fi @@ -115,6 +117,7 @@ jobs: echo "clock-web=true" >> $GITHUB_OUTPUT echo "contacts-backend=true" >> $GITHUB_OUTPUT echo "contacts-web=true" >> $GITHUB_OUTPUT + echo "telegram-stats-bot=true" >> $GITHUB_OUTPUT echo "any-changes=true" >> $GITHUB_OUTPUT exit 0 fi @@ -242,6 +245,14 @@ jobs: echo "contacts-web=false" >> $GITHUB_OUTPUT fi + # telegram-stats-bot + TELEGRAM_STATS_BOT_CHANGED=$(check_pattern "services/telegram-stats-bot/") + if [ "$COMMON_CHANGED" == "true" ] || [ "$TELEGRAM_STATS_BOT_CHANGED" == "true" ]; then + echo "telegram-stats-bot=true" >> $GITHUB_OUTPUT + else + echo "telegram-stats-bot=false" >> $GITHUB_OUTPUT + fi + # Check if any service needs building if grep -q "=true" $GITHUB_OUTPUT; then echo "any-changes=true" >> $GITHUB_OUTPUT @@ -267,6 +278,7 @@ jobs: echo "| clock-web | ${{ steps.changes.outputs.clock-web }} |" >> $GITHUB_STEP_SUMMARY echo "| contacts-backend | ${{ steps.changes.outputs.contacts-backend }} |" >> $GITHUB_STEP_SUMMARY echo "| contacts-web | ${{ steps.changes.outputs.contacts-web }} |" >> $GITHUB_STEP_SUMMARY + echo "| telegram-stats-bot | ${{ steps.changes.outputs.telegram-stats-bot }} |" >> $GITHUB_STEP_SUMMARY # =========================================== # Validation job - runs on PRs @@ -653,3 +665,32 @@ jobs: tags: ${{ steps.meta.outputs.tags }} cache-from: type=gha cache-to: type=gha,mode=max + + build-telegram-stats-bot: + name: Build telegram-stats-bot + runs-on: ubuntu-latest + needs: detect-changes + if: needs.detect-changes.outputs.telegram-stats-bot == 'true' + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: docker/metadata-action@v5 + id: meta + with: + images: ghcr.io/${{ github.repository_owner }}/telegram-stats-bot + tags: type=raw,value=latest + - uses: docker/build-push-action@v5 + with: + context: . + file: services/telegram-stats-bot/Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/services/telegram-stats-bot/.env.example b/services/telegram-stats-bot/.env.example new file mode 100644 index 000000000..9d6580395 --- /dev/null +++ b/services/telegram-stats-bot/.env.example @@ -0,0 +1,15 @@ +# Server +PORT=3300 +TZ=Europe/Berlin + +# Telegram Bot +TELEGRAM_BOT_TOKEN=your-telegram-bot-token +TELEGRAM_CHAT_ID=your-telegram-chat-id + +# Umami Analytics +UMAMI_API_URL=http://localhost:3200 +UMAMI_USERNAME=admin +UMAMI_PASSWORD=your-umami-password + +# Database (for user counts - optional) +DATABASE_URL=postgresql://postgres:password@localhost:5432/manacore_auth diff --git a/services/telegram-stats-bot/CLAUDE.md b/services/telegram-stats-bot/CLAUDE.md new file mode 100644 index 000000000..ed06a0103 --- /dev/null +++ b/services/telegram-stats-bot/CLAUDE.md @@ -0,0 +1,150 @@ +# Telegram Stats Bot - Claude Code Guidelines + +## Overview + +Telegram Stats Bot delivers analytics and statistics from Umami (stats.mana.how) via Telegram. It provides both automated scheduled reports and on-demand commands. + +## Tech Stack + +- **Framework**: NestJS 10 +- **Telegram**: nestjs-telegraf + Telegraf +- **Scheduling**: @nestjs/schedule +- **Analytics**: Umami API + +## Commands + +```bash +# Development +pnpm start:dev # Start with hot reload + +# Build +pnpm build # Production build + +# Type check +pnpm type-check # Check TypeScript types +``` + +## Project Structure + +``` +services/telegram-stats-bot/ +├── src/ +│ ├── main.ts # Application entry point +│ ├── app.module.ts # Root module +│ ├── health.controller.ts # Health check endpoint +│ ├── config/ +│ │ └── configuration.ts # Configuration & website IDs +│ ├── bot/ +│ │ ├── bot.module.ts +│ │ ├── bot.service.ts # Send messages to Telegram +│ │ └── bot.update.ts # Command handlers +│ ├── umami/ +│ │ ├── umami.module.ts +│ │ └── umami.service.ts # Umami API client +│ ├── analytics/ +│ │ ├── analytics.module.ts +│ │ ├── analytics.service.ts # Data aggregation +│ │ └── formatters.ts # Message formatters +│ ├── users/ +│ │ ├── users.module.ts +│ │ └── users.service.ts # User count from auth DB +│ └── scheduler/ +│ ├── scheduler.module.ts +│ └── report.scheduler.ts # Cron jobs +└── Dockerfile +``` + +## Telegram Commands + +| Command | Description | +|---------|-------------| +| `/start` | Show help | +| `/stats` | Overview of all apps (last 30 days) | +| `/today` | Today's statistics | +| `/week` | This week's statistics | +| `/realtime` | Active visitors right now | +| `/users` | Registered user statistics | +| `/help` | Show available commands | + +## Scheduled Reports + +| Report | Schedule | Timezone | +|--------|----------|----------| +| Daily | 09:00 | Europe/Berlin | +| Weekly | Monday 09:00 | Europe/Berlin | + +## Environment Variables + +```env +# Server +PORT=3300 +TZ=Europe/Berlin + +# Telegram +TELEGRAM_BOT_TOKEN=xxx +TELEGRAM_CHAT_ID=xxx + +# Umami +UMAMI_API_URL=http://umami:3000 +UMAMI_USERNAME=admin +UMAMI_PASSWORD=xxx + +# Database (optional, for user counts) +DATABASE_URL=postgresql://... +``` + +## Adding New Website IDs + +Edit `src/config/configuration.ts`: + +```typescript +export const WEBSITE_IDS: Record = { + 'new-app-webapp': 'uuid-from-umami', +}; + +export const DISPLAY_NAMES: Record = { + 'new-app-webapp': 'New App', +}; +``` + +## Docker + +```bash +# Build locally +docker build -f services/telegram-stats-bot/Dockerfile -t telegram-stats-bot . + +# Run +docker run -p 3300:3300 \ + -e TELEGRAM_BOT_TOKEN=xxx \ + -e TELEGRAM_CHAT_ID=xxx \ + -e UMAMI_API_URL=http://umami:3000 \ + -e UMAMI_USERNAME=admin \ + -e UMAMI_PASSWORD=xxx \ + telegram-stats-bot +``` + +## Health Check + +```bash +curl http://localhost:3300/health +``` + +## Testing Bot Commands + +In Telegram, send commands to your bot: + +``` +/start # Shows help message +/today # Gets today's stats +/week # Gets weekly stats +/realtime # Shows active visitors +``` + +## Key Files + +| File | Purpose | +|------|---------| +| `src/config/configuration.ts` | All Umami website IDs | +| `src/analytics/formatters.ts` | Report formatting | +| `src/scheduler/report.scheduler.ts` | Cron job definitions | +| `src/umami/umami.service.ts` | Umami API authentication | diff --git a/services/telegram-stats-bot/Dockerfile b/services/telegram-stats-bot/Dockerfile new file mode 100644 index 000000000..0400c3e39 --- /dev/null +++ b/services/telegram-stats-bot/Dockerfile @@ -0,0 +1,58 @@ +# Build stage +FROM node:20-alpine AS builder + +# Install pnpm +RUN npm install -g pnpm@9.15.0 + +WORKDIR /app + +# Copy package files for telegram-stats-bot only (standalone build) +COPY services/telegram-stats-bot/package.json ./ + +# Install all dependencies (including devDependencies for build) +RUN pnpm install + +# Copy source code +COPY services/telegram-stats-bot/src ./src +COPY services/telegram-stats-bot/tsconfig*.json ./ +COPY services/telegram-stats-bot/nest-cli.json ./ + +# Build the application +RUN pnpm build + +# Production stage +FROM node:20-alpine AS production + +# Install pnpm +RUN npm install -g pnpm@9.15.0 + +WORKDIR /app + +# Copy package files +COPY --from=builder /app/package.json ./ + +# Install production dependencies only +RUN pnpm install --prod + +# Copy built application +COPY --from=builder /app/dist ./dist + +# Create non-root user +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nestjs -u 1001 + +# Change ownership +RUN chown -R nestjs:nodejs /app + +# Switch to non-root user +USER nestjs + +# Expose port +EXPOSE 3300 + +# Health check +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD node -e "require('http').get('http://localhost:3300/health', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + +# Start the application +CMD ["node", "dist/main"] diff --git a/services/telegram-stats-bot/nest-cli.json b/services/telegram-stats-bot/nest-cli.json new file mode 100644 index 000000000..95538fb90 --- /dev/null +++ b/services/telegram-stats-bot/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/services/telegram-stats-bot/package.json b/services/telegram-stats-bot/package.json new file mode 100644 index 000000000..4b51b0c62 --- /dev/null +++ b/services/telegram-stats-bot/package.json @@ -0,0 +1,38 @@ +{ + "name": "@manacore/telegram-stats-bot", + "version": "1.0.0", + "description": "Telegram bot for ManaCore analytics and statistics from Umami", + "private": true, + "license": "MIT", + "scripts": { + "prebuild": "rimraf dist", + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "@nestjs/common": "^10.4.15", + "@nestjs/config": "^3.3.0", + "@nestjs/core": "^10.4.15", + "@nestjs/platform-express": "^10.4.15", + "@nestjs/schedule": "^4.1.2", + "nestjs-telegraf": "^2.8.0", + "telegraf": "^4.16.3", + "drizzle-orm": "^0.38.3", + "postgres": "^3.4.5", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.9", + "@nestjs/schematics": "^10.2.3", + "@types/node": "^22.10.5", + "rimraf": "^6.0.1", + "typescript": "^5.7.3" + } +} diff --git a/services/telegram-stats-bot/src/analytics/analytics.module.ts b/services/telegram-stats-bot/src/analytics/analytics.module.ts new file mode 100644 index 000000000..30ac251e4 --- /dev/null +++ b/services/telegram-stats-bot/src/analytics/analytics.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { UmamiModule } from '../umami/umami.module'; +import { AnalyticsService } from './analytics.service'; + +@Module({ + imports: [UmamiModule], + providers: [AnalyticsService], + exports: [AnalyticsService], +}) +export class AnalyticsModule {} diff --git a/services/telegram-stats-bot/src/analytics/analytics.service.ts b/services/telegram-stats-bot/src/analytics/analytics.service.ts new file mode 100644 index 000000000..aafea29be --- /dev/null +++ b/services/telegram-stats-bot/src/analytics/analytics.service.ts @@ -0,0 +1,122 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { UmamiService, UmamiStats } from '../umami/umami.service'; +import { + formatDailyReport, + formatWeeklyReport, + formatRealtimeReport, + formatStatsOverview, +} from './formatters'; + +@Injectable() +export class AnalyticsService { + private readonly logger = new Logger(AnalyticsService.name); + + constructor(private readonly umamiService: UmamiService) {} + + private getStartOfDay(date: Date = new Date()): Date { + const start = new Date(date); + start.setHours(0, 0, 0, 0); + return start; + } + + private getEndOfDay(date: Date = new Date()): Date { + const end = new Date(date); + end.setHours(23, 59, 59, 999); + return end; + } + + private getStartOfWeek(date: Date = new Date()): Date { + const start = new Date(date); + const day = start.getDay(); + const diff = start.getDate() - day + (day === 0 ? -6 : 1); // Adjust for Monday start + start.setDate(diff); + start.setHours(0, 0, 0, 0); + return start; + } + + private getEndOfWeek(date: Date = new Date()): Date { + const end = this.getStartOfWeek(date); + end.setDate(end.getDate() + 6); + end.setHours(23, 59, 59, 999); + return end; + } + + async getTodayStats(): Promise> { + const startAt = this.getStartOfDay().getTime(); + const endAt = this.getEndOfDay().getTime(); + return this.umamiService.getAllWebsiteStats(startAt, endAt); + } + + async getYesterdayStats(): Promise> { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const startAt = this.getStartOfDay(yesterday).getTime(); + const endAt = this.getEndOfDay(yesterday).getTime(); + return this.umamiService.getAllWebsiteStats(startAt, endAt); + } + + async getWeekStats(): Promise> { + const startAt = this.getStartOfWeek().getTime(); + const endAt = this.getEndOfWeek().getTime(); + return this.umamiService.getAllWebsiteStats(startAt, endAt); + } + + async getPreviousWeekStats(): Promise> { + const prevWeekStart = this.getStartOfWeek(); + prevWeekStart.setDate(prevWeekStart.getDate() - 7); + const prevWeekEnd = this.getEndOfWeek(prevWeekStart); + return this.umamiService.getAllWebsiteStats(prevWeekStart.getTime(), prevWeekEnd.getTime()); + } + + async getRealtimeStats(): Promise> { + return this.umamiService.getAllActiveVisitors(); + } + + async generateDailyReport(): Promise { + try { + const stats = await this.getTodayStats(); + return formatDailyReport(stats, new Date()); + } catch (error) { + this.logger.error('Failed to generate daily report:', error); + return '❌ Fehler beim Erstellen des Daily Reports'; + } + } + + async generateWeeklyReport(): Promise { + try { + const stats = await this.getWeekStats(); + const prevStats = await this.getPreviousWeekStats(); + const weekStart = this.getStartOfWeek(); + const weekEnd = this.getEndOfWeek(); + return formatWeeklyReport(stats, weekStart, weekEnd, prevStats); + } catch (error) { + this.logger.error('Failed to generate weekly report:', error); + return '❌ Fehler beim Erstellen des Weekly Reports'; + } + } + + async generateRealtimeReport(): Promise { + try { + const activeVisitors = await this.getRealtimeStats(); + return formatRealtimeReport(activeVisitors); + } catch (error) { + this.logger.error('Failed to generate realtime report:', error); + return '❌ Fehler beim Abrufen der Realtime-Daten'; + } + } + + async generateStatsOverview(): Promise { + try { + // Get last 30 days stats for overview + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const startAt = this.getStartOfDay(thirtyDaysAgo).getTime(); + const endAt = this.getEndOfDay().getTime(); + const stats = await this.umamiService.getAllWebsiteStats(startAt, endAt); + return formatStatsOverview(stats); + } catch (error) { + this.logger.error('Failed to generate stats overview:', error); + return '❌ Fehler beim Abrufen der Statistiken'; + } + } +} diff --git a/services/telegram-stats-bot/src/analytics/formatters.ts b/services/telegram-stats-bot/src/analytics/formatters.ts new file mode 100644 index 000000000..d8d6f0b83 --- /dev/null +++ b/services/telegram-stats-bot/src/analytics/formatters.ts @@ -0,0 +1,229 @@ +import { DISPLAY_NAMES } from '../config/configuration'; +import { UmamiStats } from '../umami/umami.service'; + +export function formatNumber(num: number): string { + return num.toLocaleString('de-DE'); +} + +export function formatChange(change: number): string { + if (change === 0) return '→'; + const sign = change > 0 ? '+' : ''; + return `${sign}${Math.round(change)}%`; +} + +export function formatChangeEmoji(change: number): string { + if (change > 10) return '📈'; + if (change > 0) return '↗'; + if (change < -10) return '📉'; + if (change < 0) return '↘'; + return '→'; +} + +export function getDisplayName(websiteKey: string): string { + return DISPLAY_NAMES[websiteKey] || websiteKey; +} + +export function formatDate(date: Date, format: 'short' | 'long' = 'short'): string { + const options: Intl.DateTimeFormatOptions = + format === 'short' + ? { day: 'numeric', month: 'numeric', year: 'numeric' } + : { day: 'numeric', month: 'long', year: 'numeric' }; + return date.toLocaleDateString('de-DE', options); +} + +export function formatWeekNumber(date: Date): string { + const startOfYear = new Date(date.getFullYear(), 0, 1); + const days = Math.floor((date.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000)); + const weekNumber = Math.ceil((days + startOfYear.getDay() + 1) / 7); + return `KW ${weekNumber}`; +} + +export function formatDailyReport(stats: Map, date: Date): string { + const lines: string[] = [ + '📊 ManaCore Daily Report', + '━━━━━━━━━━━━━━━━━━━━', + '', + `📅 ${formatDate(date, 'long')}`, + '', + '📈 Besucher heute:', + ]; + + // Sort by visitors (descending) + const sortedStats = Array.from(stats.entries()) + .filter(([key]) => key.endsWith('-webapp')) + .sort((a, b) => b[1].visitors.value - a[1].visitors.value); + + let totalVisitors = 0; + let totalPageviews = 0; + + for (const [key, stat] of sortedStats) { + const name = getDisplayName(key).padEnd(12); + const visitors = stat.visitors.value; + const change = formatChange(stat.visitors.change); + const emoji = formatChangeEmoji(stat.visitors.change); + + totalVisitors += visitors; + totalPageviews += stat.pageviews.value; + + lines.push(` ${name}: ${formatNumber(visitors)} (${change}) ${emoji}`); + } + + lines.push(''); + lines.push(`📄 Pageviews: ${formatNumber(totalPageviews)}`); + lines.push(`👥 Besucher gesamt: ${formatNumber(totalVisitors)}`); + + return lines.join('\n'); +} + +export function formatWeeklyReport( + stats: Map, + weekStart: Date, + weekEnd: Date, + prevStats?: Map +): string { + const lines: string[] = [ + '📊 ManaCore Weekly Report', + '━━━━━━━━━━━━━━━━━━━━', + '', + `📅 ${formatWeekNumber(weekStart)} (${formatDate(weekStart)} - ${formatDate(weekEnd)})`, + '', + ' Besucher Pageviews', + ]; + + // Sort by visitors (descending) + const sortedStats = Array.from(stats.entries()) + .filter(([key]) => key.endsWith('-webapp')) + .sort((a, b) => b[1].visitors.value - a[1].visitors.value); + + let totalVisitors = 0; + let totalPageviews = 0; + + for (const [key, stat] of sortedStats) { + const name = getDisplayName(key).padEnd(12); + const visitors = formatNumber(stat.visitors.value).padStart(6); + const pageviews = formatNumber(stat.pageviews.value).padStart(9); + + totalVisitors += stat.visitors.value; + totalPageviews += stat.pageviews.value; + + lines.push(`${name}: ${visitors} ${pageviews}`); + } + + lines.push('────────────────────────────'); + lines.push( + `Total: ${formatNumber(totalVisitors).padStart(6)} ${formatNumber(totalPageviews).padStart(9)}` + ); + + // Calculate week-over-week change if previous stats available + if (prevStats) { + let prevTotal = 0; + for (const [key, stat] of prevStats.entries()) { + if (key.endsWith('-webapp')) { + prevTotal += stat.visitors.value; + } + } + if (prevTotal > 0) { + const change = ((totalVisitors - prevTotal) / prevTotal) * 100; + lines.push(''); + lines.push(`📊 vs. Vorwoche: ${formatChange(change)} ${formatChangeEmoji(change)}`); + } + } + + return lines.join('\n'); +} + +export function formatRealtimeReport(activeVisitors: Map): string { + const lines: string[] = ['🔴 Realtime - Aktive Besucher', '━━━━━━━━━━━━━━━━━━━━', '']; + + // Sort by active visitors (descending) + const sortedVisitors = Array.from(activeVisitors.entries()) + .filter(([key]) => key.endsWith('-webapp')) + .sort((a, b) => b[1] - a[1]); + + let total = 0; + + for (const [key, count] of sortedVisitors) { + const name = getDisplayName(key).padEnd(12); + total += count; + const indicator = count > 0 ? '🟢' : '⚪'; + lines.push(`${indicator} ${name}: ${count}`); + } + + lines.push(''); + lines.push(`👥 Gesamt aktiv: ${total}`); + + return lines.join('\n'); +} + +export function formatStatsOverview(stats: Map): string { + const lines: string[] = ['📊 ManaCore Stats Übersicht', '━━━━━━━━━━━━━━━━━━━━', '']; + + // Group by type + const webapps = Array.from(stats.entries()) + .filter(([key]) => key.endsWith('-webapp')) + .sort((a, b) => b[1].visitors.value - a[1].visitors.value); + + const landings = Array.from(stats.entries()) + .filter(([key]) => key.endsWith('-landing')) + .sort((a, b) => b[1].visitors.value - a[1].visitors.value); + + lines.push('🌐 Web Apps:'); + for (const [key, stat] of webapps) { + const name = getDisplayName(key).padEnd(12); + lines.push(` ${name}: ${formatNumber(stat.visitors.value)} visitors`); + } + + if (landings.length > 0) { + lines.push(''); + lines.push('🏠 Landing Pages:'); + for (const [key, stat] of landings) { + const name = getDisplayName(key).padEnd(12); + lines.push(` ${name}: ${formatNumber(stat.visitors.value)} visitors`); + } + } + + return lines.join('\n'); +} + +export function formatHelpMessage(): string { + return `🤖 ManaCore Stats Bot +━━━━━━━━━━━━━━━━━━━━ + +Verfügbare Befehle: + +/stats - Übersicht aller Apps +/today - Heutige Statistiken +/week - Wochenstatistiken +/realtime - Aktive Besucher jetzt +/users - Registrierte User +/help - Diese Hilfe anzeigen + +📅 Automatische Reports: +• Daily: Jeden Tag um 9:00 +• Weekly: Jeden Montag um 9:00`; +} + +export interface UserStats { + totalUsers: number; + verifiedUsers: number; + todayNewUsers: number; + weekNewUsers: number; + monthNewUsers: number; +} + +export function formatUsersReport(stats: UserStats): string { + const lines: string[] = [ + '👥 ManaCore User Statistics', + '━━━━━━━━━━━━━━━━━━━━', + '', + `👤 Gesamt: ${formatNumber(stats.totalUsers)}`, + `✅ Verifiziert: ${formatNumber(stats.verifiedUsers)}`, + '', + '📊 Neue Registrierungen:', + ` Heute: +${formatNumber(stats.todayNewUsers)}`, + ` Diese Woche: +${formatNumber(stats.weekNewUsers)}`, + ` Dieser Monat: +${formatNumber(stats.monthNewUsers)}`, + ]; + + return lines.join('\n'); +} diff --git a/services/telegram-stats-bot/src/app.module.ts b/services/telegram-stats-bot/src/app.module.ts new file mode 100644 index 000000000..df0fc3beb --- /dev/null +++ b/services/telegram-stats-bot/src/app.module.ts @@ -0,0 +1,36 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { ScheduleModule } from '@nestjs/schedule'; +import { TelegrafModule } from 'nestjs-telegraf'; +import configuration from './config/configuration'; +import { BotModule } from './bot/bot.module'; +import { UmamiModule } from './umami/umami.module'; +import { AnalyticsModule } from './analytics/analytics.module'; +import { SchedulerModule } from './scheduler/scheduler.module'; +import { HealthController } from './health.controller'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [configuration], + }), + ScheduleModule.forRoot(), + TelegrafModule.forRootAsync({ + imports: [ConfigModule], + useFactory: (configService: ConfigService) => ({ + token: configService.get('telegram.botToken') || '', + launchOptions: { + dropPendingUpdates: true, + }, + }), + inject: [ConfigService], + }), + BotModule, + UmamiModule, + AnalyticsModule, + SchedulerModule, + ], + controllers: [HealthController], +}) +export class AppModule {} diff --git a/services/telegram-stats-bot/src/bot/bot.module.ts b/services/telegram-stats-bot/src/bot/bot.module.ts new file mode 100644 index 000000000..f6134cccc --- /dev/null +++ b/services/telegram-stats-bot/src/bot/bot.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AnalyticsModule } from '../analytics/analytics.module'; +import { UsersModule } from '../users/users.module'; +import { BotService } from './bot.service'; +import { BotUpdate } from './bot.update'; + +@Module({ + imports: [AnalyticsModule, UsersModule], + providers: [BotService, BotUpdate], + exports: [BotService], +}) +export class BotModule {} diff --git a/services/telegram-stats-bot/src/bot/bot.service.ts b/services/telegram-stats-bot/src/bot/bot.service.ts new file mode 100644 index 000000000..42eddae36 --- /dev/null +++ b/services/telegram-stats-bot/src/bot/bot.service.ts @@ -0,0 +1,40 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectBot } from 'nestjs-telegraf'; +import { Telegraf, Context } from 'telegraf'; + +@Injectable() +export class BotService { + private readonly logger = new Logger(BotService.name); + private readonly chatId: string; + + constructor( + @InjectBot() private readonly bot: Telegraf, + private readonly configService: ConfigService + ) { + this.chatId = this.configService.get('telegram.chatId') || ''; + } + + async sendMessage(message: string, chatId?: string): Promise { + const targetChatId = chatId || this.chatId; + + if (!targetChatId) { + this.logger.warn('No chat ID configured, skipping message'); + return; + } + + try { + await this.bot.telegram.sendMessage(targetChatId, message, { + parse_mode: 'HTML', + }); + this.logger.log(`Message sent to chat ${targetChatId}`); + } catch (error) { + this.logger.error(`Failed to send message: ${error}`); + throw error; + } + } + + async sendReport(report: string): Promise { + return this.sendMessage(report); + } +} diff --git a/services/telegram-stats-bot/src/bot/bot.update.ts b/services/telegram-stats-bot/src/bot/bot.update.ts new file mode 100644 index 000000000..31a906872 --- /dev/null +++ b/services/telegram-stats-bot/src/bot/bot.update.ts @@ -0,0 +1,79 @@ +import { Logger } from '@nestjs/common'; +import { Update, Ctx, Start, Help, Command } from 'nestjs-telegraf'; +import { Context } from 'telegraf'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { UsersService } from '../users/users.service'; +import { formatHelpMessage, formatUsersReport } from '../analytics/formatters'; + +@Update() +export class BotUpdate { + private readonly logger = new Logger(BotUpdate.name); + + constructor( + private readonly analyticsService: AnalyticsService, + private readonly usersService: UsersService + ) {} + + @Start() + async start(@Ctx() ctx: Context) { + this.logger.log(`/start command from ${ctx.from?.id}`); + await ctx.replyWithHTML(formatHelpMessage()); + } + + @Help() + async help(@Ctx() ctx: Context) { + this.logger.log(`/help command from ${ctx.from?.id}`); + await ctx.replyWithHTML(formatHelpMessage()); + } + + @Command('stats') + async stats(@Ctx() ctx: Context) { + this.logger.log(`/stats command from ${ctx.from?.id}`); + await ctx.reply('📊 Lade Statistiken...'); + + const report = await this.analyticsService.generateStatsOverview(); + await ctx.replyWithHTML(report); + } + + @Command('today') + async today(@Ctx() ctx: Context) { + this.logger.log(`/today command from ${ctx.from?.id}`); + await ctx.reply('📊 Lade heutige Statistiken...'); + + const report = await this.analyticsService.generateDailyReport(); + await ctx.replyWithHTML(report); + } + + @Command('week') + async week(@Ctx() ctx: Context) { + this.logger.log(`/week command from ${ctx.from?.id}`); + await ctx.reply('📊 Lade Wochenstatistiken...'); + + const report = await this.analyticsService.generateWeeklyReport(); + await ctx.replyWithHTML(report); + } + + @Command('realtime') + async realtime(@Ctx() ctx: Context) { + this.logger.log(`/realtime command from ${ctx.from?.id}`); + await ctx.reply('🔴 Lade Realtime-Daten...'); + + const report = await this.analyticsService.generateRealtimeReport(); + await ctx.replyWithHTML(report); + } + + @Command('users') + async users(@Ctx() ctx: Context) { + this.logger.log(`/users command from ${ctx.from?.id}`); + await ctx.reply('👥 Lade User-Statistiken...'); + + const stats = await this.usersService.getUserStats(); + if (!stats) { + await ctx.reply('❌ Datenbank nicht verfügbar'); + return; + } + + const report = formatUsersReport(stats); + await ctx.replyWithHTML(report); + } +} diff --git a/services/telegram-stats-bot/src/config/configuration.ts b/services/telegram-stats-bot/src/config/configuration.ts new file mode 100644 index 000000000..e284628bd --- /dev/null +++ b/services/telegram-stats-bot/src/config/configuration.ts @@ -0,0 +1,71 @@ +export default () => ({ + port: parseInt(process.env.PORT || '3300', 10), + timezone: process.env.TZ || 'Europe/Berlin', + telegram: { + botToken: process.env.TELEGRAM_BOT_TOKEN, + chatId: process.env.TELEGRAM_CHAT_ID, + }, + umami: { + apiUrl: process.env.UMAMI_API_URL || 'http://localhost:3200', + username: process.env.UMAMI_USERNAME || 'admin', + password: process.env.UMAMI_PASSWORD, + }, + database: { + url: process.env.DATABASE_URL, + }, +}); + +export const WEBSITE_IDS: Record = { + // Landing Pages + 'chat-landing': 'a264b165-80d2-47ab-91f4-2efc01de0b66', + 'manacore-landing': 'cef3798d-85ae-47df-a44a-e9bee09dbcf9', + 'clock-landing': '0332b471-a022-46af-a726-0f45932bfd58', + + // Web Apps + 'chat-webapp': '5cf9d569-3266-4a57-80dd-3a652dc32786', + 'manacore-webapp': '4a14016d-394a-44e0-8ecc-67271f63ffb0', + 'todo-webapp': 'ac021d98-778e-46cf-b6b2-2f650ea78f07', + 'calendar-webapp': '884fc0a8-3b67-43bd-903b-2be531c66792', + 'clock-webapp': '1e7b5006-87a5-4547-8a3d-ab30eac15dd4', + 'contacts-webapp': 'ab89a839-be15-4949-99b4-e72492cee4ff', + 'picture-webapp': 'bc552bd2-667d-44b4-a717-0dce6a8db98f', + 'manadeck-webapp': '314fc57a-c63d-4008-b19e-5e272c0329d6', + 'planta-webapp': '876f30bd-43e3-405a-9697-6157db67ca6b', + 'zitare-landing': '17e7f92d-8f85-4e78-a4f5-10f0b47e8fb8', + 'zitare-webapp': '8ad3c21f-6e9b-4d1e-b3a2-5c8f7d6e9a4b', +}; + +// Grouped websites for reporting +export const WEBSITE_GROUPS = { + landings: ['chat-landing', 'manacore-landing', 'clock-landing', 'zitare-landing'], + webapps: [ + 'manacore-webapp', + 'chat-webapp', + 'todo-webapp', + 'calendar-webapp', + 'clock-webapp', + 'contacts-webapp', + 'picture-webapp', + 'manadeck-webapp', + 'planta-webapp', + 'zitare-webapp', + ], +}; + +// Display names for reports +export const DISPLAY_NAMES: Record = { + 'chat-landing': 'Chat Landing', + 'chat-webapp': 'Chat', + 'manacore-landing': 'ManaCore Landing', + 'manacore-webapp': 'ManaCore', + 'todo-webapp': 'Todo', + 'calendar-webapp': 'Calendar', + 'clock-landing': 'Clock Landing', + 'clock-webapp': 'Clock', + 'contacts-webapp': 'Contacts', + 'picture-webapp': 'Picture', + 'manadeck-webapp': 'ManaDeck', + 'planta-webapp': 'Planta', + 'zitare-landing': 'Zitare Landing', + 'zitare-webapp': 'Zitare', +}; diff --git a/services/telegram-stats-bot/src/health.controller.ts b/services/telegram-stats-bot/src/health.controller.ts new file mode 100644 index 000000000..33c9821ce --- /dev/null +++ b/services/telegram-stats-bot/src/health.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller() +export class HealthController { + @Get('health') + health() { + return { + status: 'ok', + service: 'telegram-stats-bot', + timestamp: new Date().toISOString(), + }; + } +} diff --git a/services/telegram-stats-bot/src/main.ts b/services/telegram-stats-bot/src/main.ts new file mode 100644 index 000000000..2aeb47dde --- /dev/null +++ b/services/telegram-stats-bot/src/main.ts @@ -0,0 +1,18 @@ +import { NestFactory } from '@nestjs/core'; +import { Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AppModule } from './app.module'; + +async function bootstrap() { + const logger = new Logger('Bootstrap'); + const app = await NestFactory.create(AppModule); + + const configService = app.get(ConfigService); + const port = configService.get('port') || 3300; + + await app.listen(port); + logger.log(`Telegram Stats Bot running on port ${port}`); + logger.log(`Timezone: ${configService.get('timezone')}`); +} + +bootstrap(); diff --git a/services/telegram-stats-bot/src/scheduler/report.scheduler.ts b/services/telegram-stats-bot/src/scheduler/report.scheduler.ts new file mode 100644 index 000000000..c6e38a19b --- /dev/null +++ b/services/telegram-stats-bot/src/scheduler/report.scheduler.ts @@ -0,0 +1,66 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { AnalyticsService } from '../analytics/analytics.service'; +import { BotService } from '../bot/bot.service'; + +@Injectable() +export class ReportScheduler { + private readonly logger = new Logger(ReportScheduler.name); + + constructor( + private readonly analyticsService: AnalyticsService, + private readonly botService: BotService + ) {} + + /** + * Daily Report - Every day at 9:00 AM Europe/Berlin + * Cron: minute hour day month weekday + */ + @Cron('0 9 * * *', { + name: 'daily-report', + timeZone: 'Europe/Berlin', + }) + async sendDailyReport(): Promise { + this.logger.log('Starting daily report...'); + + try { + const report = await this.analyticsService.generateDailyReport(); + await this.botService.sendReport(report); + this.logger.log('Daily report sent successfully'); + } catch (error) { + this.logger.error('Failed to send daily report:', error); + } + } + + /** + * Weekly Report - Every Monday at 9:00 AM Europe/Berlin + * Cron: minute hour day month weekday (1 = Monday) + */ + @Cron('0 9 * * 1', { + name: 'weekly-report', + timeZone: 'Europe/Berlin', + }) + async sendWeeklyReport(): Promise { + this.logger.log('Starting weekly report...'); + + try { + const report = await this.analyticsService.generateWeeklyReport(); + await this.botService.sendReport(report); + this.logger.log('Weekly report sent successfully'); + } catch (error) { + this.logger.error('Failed to send weekly report:', error); + } + } + + /** + * Health check log - Every hour + * Useful for debugging and ensuring the scheduler is running + */ + @Cron('0 * * * *', { + name: 'scheduler-health', + timeZone: 'Europe/Berlin', + }) + healthCheck(): void { + this.logger.debug('Scheduler health check - running'); + } +} diff --git a/services/telegram-stats-bot/src/scheduler/scheduler.module.ts b/services/telegram-stats-bot/src/scheduler/scheduler.module.ts new file mode 100644 index 000000000..a76e6c92e --- /dev/null +++ b/services/telegram-stats-bot/src/scheduler/scheduler.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { AnalyticsModule } from '../analytics/analytics.module'; +import { BotModule } from '../bot/bot.module'; +import { ReportScheduler } from './report.scheduler'; + +@Module({ + imports: [AnalyticsModule, BotModule], + providers: [ReportScheduler], +}) +export class SchedulerModule {} diff --git a/services/telegram-stats-bot/src/umami/umami.module.ts b/services/telegram-stats-bot/src/umami/umami.module.ts new file mode 100644 index 000000000..b9bb7b2bd --- /dev/null +++ b/services/telegram-stats-bot/src/umami/umami.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { UmamiService } from './umami.service'; + +@Module({ + providers: [UmamiService], + exports: [UmamiService], +}) +export class UmamiModule {} diff --git a/services/telegram-stats-bot/src/umami/umami.service.ts b/services/telegram-stats-bot/src/umami/umami.service.ts new file mode 100644 index 000000000..eb2d52e54 --- /dev/null +++ b/services/telegram-stats-bot/src/umami/umami.service.ts @@ -0,0 +1,135 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { WEBSITE_IDS } from '../config/configuration'; + +export interface UmamiStats { + pageviews: { value: number; change: number }; + visitors: { value: number; change: number }; + visits: { value: number; change: number }; + bounces: { value: number; change: number }; + totalTime: { value: number; change: number }; +} + +export interface ActiveVisitors { + websiteId: string; + visitors: number; +} + +@Injectable() +export class UmamiService implements OnModuleInit { + private readonly logger = new Logger(UmamiService.name); + private apiUrl: string; + private username: string; + private password: string; + private authToken: string | null = null; + private tokenExpiry: Date | null = null; + + constructor(private configService: ConfigService) { + this.apiUrl = this.configService.get('umami.apiUrl') || 'http://localhost:3200'; + this.username = this.configService.get('umami.username') || 'admin'; + this.password = this.configService.get('umami.password') || ''; + } + + async onModuleInit() { + try { + await this.authenticate(); + this.logger.log('Successfully authenticated with Umami'); + } catch (error) { + this.logger.warn( + 'Failed to authenticate with Umami on startup. Will retry on first request.' + ); + } + } + + private async authenticate(): Promise { + const response = await fetch(`${this.apiUrl}/api/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: this.username, + password: this.password, + }), + }); + + if (!response.ok) { + throw new Error(`Umami auth failed: ${response.status}`); + } + + const data = await response.json(); + this.authToken = data.token; + // Token is valid for 24 hours, refresh after 23 hours + this.tokenExpiry = new Date(Date.now() + 23 * 60 * 60 * 1000); + } + + private async getAuthToken(): Promise { + if (!this.authToken || !this.tokenExpiry || this.tokenExpiry < new Date()) { + await this.authenticate(); + } + return this.authToken!; + } + + private async apiRequest(endpoint: string): Promise { + const token = await this.getAuthToken(); + const response = await fetch(`${this.apiUrl}${endpoint}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + }); + + if (!response.ok) { + throw new Error(`Umami API error: ${response.status} ${await response.text()}`); + } + + return response.json(); + } + + async getWebsiteStats(websiteId: string, startAt: number, endAt: number): Promise { + return this.apiRequest( + `/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}` + ); + } + + async getActiveVisitors(websiteId: string): Promise { + try { + const result = await this.apiRequest(`/api/websites/${websiteId}/active`); + return result?.[0]?.visitors || 0; + } catch { + return 0; + } + } + + async getAllWebsiteStats(startAt: number, endAt: number): Promise> { + const results = new Map(); + + for (const [name, id] of Object.entries(WEBSITE_IDS)) { + try { + const stats = await this.getWebsiteStats(id, startAt, endAt); + results.set(name, stats); + } catch (error) { + this.logger.warn(`Failed to get stats for ${name}: ${error}`); + } + } + + return results; + } + + async getAllActiveVisitors(): Promise> { + const results = new Map(); + + for (const [name, id] of Object.entries(WEBSITE_IDS)) { + try { + const visitors = await this.getActiveVisitors(id); + results.set(name, visitors); + } catch (error) { + this.logger.warn(`Failed to get active visitors for ${name}: ${error}`); + } + } + + return results; + } + + getWebsiteId(name: string): string | undefined { + return WEBSITE_IDS[name]; + } +} diff --git a/services/telegram-stats-bot/src/users/users.module.ts b/services/telegram-stats-bot/src/users/users.module.ts new file mode 100644 index 000000000..00ef465ea --- /dev/null +++ b/services/telegram-stats-bot/src/users/users.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { UsersService } from './users.service'; + +@Module({ + providers: [UsersService], + exports: [UsersService], +}) +export class UsersModule {} diff --git a/services/telegram-stats-bot/src/users/users.service.ts b/services/telegram-stats-bot/src/users/users.service.ts new file mode 100644 index 000000000..afa4cb1bc --- /dev/null +++ b/services/telegram-stats-bot/src/users/users.service.ts @@ -0,0 +1,77 @@ +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import postgres from 'postgres'; + +export interface UserStats { + totalUsers: number; + verifiedUsers: number; + todayNewUsers: number; + weekNewUsers: number; + monthNewUsers: number; +} + +@Injectable() +export class UsersService implements OnModuleInit { + private readonly logger = new Logger(UsersService.name); + private sql: postgres.Sql | null = null; + private databaseUrl: string | undefined; + + constructor(private configService: ConfigService) { + this.databaseUrl = this.configService.get('database.url'); + } + + async onModuleInit() { + if (this.databaseUrl) { + try { + this.sql = postgres(this.databaseUrl); + this.logger.log('Database connection initialized'); + } catch (error) { + this.logger.warn('Failed to initialize database connection:', error); + } + } else { + this.logger.warn('DATABASE_URL not configured, user stats will be unavailable'); + } + } + + async getUserStats(): Promise { + if (!this.sql) { + return null; + } + + try { + const now = new Date(); + const startOfToday = new Date(now); + startOfToday.setHours(0, 0, 0, 0); + + const startOfWeek = new Date(now); + const day = startOfWeek.getDay(); + const diff = startOfWeek.getDate() - day + (day === 0 ? -6 : 1); + startOfWeek.setDate(diff); + startOfWeek.setHours(0, 0, 0, 0); + + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + const [result] = await this.sql` + SELECT + COUNT(*) as total_users, + COUNT(*) FILTER (WHERE email_verified = true) as verified_users, + COUNT(*) FILTER (WHERE created_at >= ${startOfToday.toISOString()}) as today_new_users, + COUNT(*) FILTER (WHERE created_at >= ${startOfWeek.toISOString()}) as week_new_users, + COUNT(*) FILTER (WHERE created_at >= ${startOfMonth.toISOString()}) as month_new_users + FROM auth.users + WHERE deleted_at IS NULL + `; + + return { + totalUsers: Number(result.total_users), + verifiedUsers: Number(result.verified_users), + todayNewUsers: Number(result.today_new_users), + weekNewUsers: Number(result.week_new_users), + monthNewUsers: Number(result.month_new_users), + }; + } catch (error) { + this.logger.error('Failed to fetch user stats:', error); + return null; + } + } +} diff --git a/services/telegram-stats-bot/tsconfig.json b/services/telegram-stats-bot/tsconfig.json new file mode 100644 index 000000000..1e5880c81 --- /dev/null +++ b/services/telegram-stats-bot/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2022", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}