From 0cd2bc858a69d7cda93fa5130af2be1005b2d2ff Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:53:57 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(stats):=20add=20user=20statist?= =?UTF-8?q?ics=20to=20Prometheus=20metrics=20and=20Grafana?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add user metrics to mana-core-auth MetricsService: - auth_users_total: Total registered users - auth_users_verified: Email-verified users - auth_users_created_today/this_week/this_month - Create Grafana user-statistics dashboard with: - User overview stats (total, verified, verification rate, new today) - Registration period breakdown (today/week/month) - User growth trends over time - Enhance telegram-stats-bot /users command: - Add yesterday comparison with trends - Add week-over-week comparison - Add mini bar chart for last 7 days registration - Include user stats in daily Telegram report --- .../grafana/dashboards/user-statistics.json | 542 ++++++++++++++++++ .../src/metrics/metrics.service.ts | 126 +++- .../src/analytics/analytics.module.ts | 3 +- .../src/analytics/analytics.service.ts | 17 +- .../src/analytics/formatters.ts | 86 ++- .../src/users/users.service.ts | 37 ++ 6 files changed, 798 insertions(+), 13 deletions(-) create mode 100644 docker/grafana/dashboards/user-statistics.json diff --git a/docker/grafana/dashboards/user-statistics.json b/docker/grafana/dashboards/user-statistics.json new file mode 100644 index 000000000..2da8b9713 --- /dev/null +++ b/docker/grafana/dashboards/user-statistics.json @@ -0,0 +1,542 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 0 }, + "id": 1, + "panels": [], + "title": "User Overview", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "blue", "value": null }] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 5, "w": 6, "x": 0, "y": 1 }, + "id": 2, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "expr": "auth_users_total", + "legendFormat": "Total Users", + "refId": "A" + } + ], + "title": "Total Users", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 5, "w": 6, "x": 6, "y": 1 }, + "id": 3, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "expr": "auth_users_verified", + "legendFormat": "Verified", + "refId": "A" + } + ], + "title": "Verified Users", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "yellow", "value": null }] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { "h": 5, "w": 6, "x": 12, "y": 1 }, + "id": 4, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "expr": "auth_users_verified / auth_users_total", + "legendFormat": "Verification Rate", + "refId": "A" + } + ], + "title": "Verification Rate", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "orange", "value": null }] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 5, "w": 6, "x": 18, "y": 1 }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "expr": "auth_users_created_today", + "legendFormat": "Today", + "refId": "A" + } + ], + "title": "New Users Today", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 6 }, + "id": 6, + "panels": [], + "title": "Registration Periods", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "purple", "value": null }] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 8, "x": 0, "y": 7 }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "expr": "auth_users_created_today", + "legendFormat": "Today", + "refId": "A" + } + ], + "title": "New Users Today", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "purple", "value": null }] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 8, "x": 8, "y": 7 }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "expr": "auth_users_created_this_week", + "legendFormat": "This Week", + "refId": "A" + } + ], + "title": "New Users This Week", + "type": "stat" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "thresholds" }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "purple", "value": null }] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 4, "w": 8, "x": 16, "y": 7 }, + "id": 9, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "horizontal", + "reduceOptions": { + "calcs": ["lastNotNull"], + "fields": "", + "values": false + }, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "expr": "auth_users_created_this_month", + "legendFormat": "This Month", + "refId": "A" + } + ], + "title": "New Users This Month", + "type": "stat" + }, + { + "collapsed": false, + "gridPos": { "h": 1, "w": 24, "x": 0, "y": 11 }, + "id": 10, + "panels": [], + "title": "User Growth Trends", + "type": "row" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 }, + "id": 11, + "options": { + "legend": { + "calcs": ["lastNotNull", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { "mode": "single", "sort": "none" } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "expr": "auth_users_total", + "legendFormat": "Total Users", + "refId": "A" + } + ], + "title": "Total Users Over Time", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "opacity", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "auto", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + }, + "unit": "percentunit" + }, + "overrides": [] + }, + "gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 }, + "id": 12, + "options": { + "legend": { + "calcs": ["lastNotNull", "mean"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { "mode": "single", "sort": "none" } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "expr": "auth_users_verified / auth_users_total", + "legendFormat": "Verification Rate", + "refId": "A" + } + ], + "title": "Verification Rate Over Time", + "type": "timeseries" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "fieldConfig": { + "defaults": { + "color": { "mode": "palette-classic" }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { "legend": false, "tooltip": false, "viz": false }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { "type": "linear" }, + "showPoints": "never", + "spanNulls": false, + "stacking": { "group": "A", "mode": "none" }, + "thresholdsStyle": { "mode": "off" } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [{ "color": "green", "value": null }] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { "id": "byName", "options": "Today" }, + "properties": [{ "id": "color", "value": { "fixedColor": "orange", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "This Week" }, + "properties": [{ "id": "color", "value": { "fixedColor": "blue", "mode": "fixed" } }] + }, + { + "matcher": { "id": "byName", "options": "This Month" }, + "properties": [{ "id": "color", "value": { "fixedColor": "purple", "mode": "fixed" } }] + } + ] + }, + "gridPos": { "h": 8, "w": 24, "x": 0, "y": 20 }, + "id": 13, + "options": { + "legend": { + "calcs": ["lastNotNull"], + "displayMode": "table", + "placement": "right", + "showLegend": true + }, + "tooltip": { "mode": "multi", "sort": "none" } + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "expr": "auth_users_created_today", + "legendFormat": "Today", + "refId": "A" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "expr": "auth_users_created_this_week", + "legendFormat": "This Week", + "refId": "B" + }, + { + "datasource": { "type": "prometheus", "uid": "${datasource}" }, + "expr": "auth_users_created_this_month", + "legendFormat": "This Month", + "refId": "C" + } + ], + "title": "New Registrations by Period", + "type": "timeseries" + } + ], + "refresh": "30s", + "schemaVersion": 38, + "tags": ["manacore", "users", "auth"], + "templating": { + "list": [ + { + "current": { "selected": false, "text": "Prometheus", "value": "Prometheus" }, + "hide": 0, + "includeAll": false, + "multi": false, + "name": "datasource", + "options": [], + "query": "prometheus", + "refresh": 1, + "regex": "", + "skipUrlSync": false, + "type": "datasource" + } + ] + }, + "time": { "from": "now-7d", "to": "now" }, + "timepicker": {}, + "timezone": "browser", + "title": "User Statistics", + "uid": "user-statistics", + "version": 1, + "weekStart": "" +} diff --git a/services/mana-core-auth/src/metrics/metrics.service.ts b/services/mana-core-auth/src/metrics/metrics.service.ts index 1ac1f71b7..ee4a997fa 100644 --- a/services/mana-core-auth/src/metrics/metrics.service.ts +++ b/services/mana-core-auth/src/metrics/metrics.service.ts @@ -1,15 +1,28 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import * as client from 'prom-client'; +import { count, eq, gte, and, isNull, sql } from 'drizzle-orm'; +import { getDb } from '../db/connection'; +import { users } from '../db/schema'; @Injectable() -export class MetricsService implements OnModuleInit { +export class MetricsService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(MetricsService.name); private readonly register: client.Registry; + private updateInterval: NodeJS.Timeout | null = null; // HTTP metrics readonly httpRequestsTotal: client.Counter; readonly httpRequestDuration: client.Histogram; - constructor() { + // User metrics + readonly usersTotal: client.Gauge; + readonly usersVerified: client.Gauge; + readonly usersCreatedToday: client.Gauge; + readonly usersCreatedThisWeek: client.Gauge; + readonly usersCreatedThisMonth: client.Gauge; + + constructor(private readonly configService: ConfigService) { this.register = new client.Registry(); // Add default metrics (CPU, memory, event loop, etc.) @@ -34,10 +47,113 @@ export class MetricsService implements OnModuleInit { buckets: [0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1, 2, 5], registers: [this.register], }); + + // User statistics gauges + this.usersTotal = new client.Gauge({ + name: 'auth_users_total', + help: 'Total number of registered users', + registers: [this.register], + }); + + this.usersVerified = new client.Gauge({ + name: 'auth_users_verified', + help: 'Number of email-verified users', + registers: [this.register], + }); + + this.usersCreatedToday = new client.Gauge({ + name: 'auth_users_created_today', + help: 'Number of users created today', + registers: [this.register], + }); + + this.usersCreatedThisWeek = new client.Gauge({ + name: 'auth_users_created_this_week', + help: 'Number of users created this week', + registers: [this.register], + }); + + this.usersCreatedThisMonth = new client.Gauge({ + name: 'auth_users_created_this_month', + help: 'Number of users created this month', + registers: [this.register], + }); } - onModuleInit() { - // Metrics are ready + async onModuleInit() { + // Update user metrics immediately and then every 60 seconds + await this.updateUserMetrics(); + this.updateInterval = setInterval(() => this.updateUserMetrics(), 60000); + } + + onModuleDestroy() { + if (this.updateInterval) { + clearInterval(this.updateInterval); + this.updateInterval = null; + } + } + + private async updateUserMetrics() { + const databaseUrl = this.configService.get('DATABASE_URL'); + if (!databaseUrl) { + this.logger.warn('DATABASE_URL not configured, user metrics unavailable'); + return; + } + + try { + const db = getDb(databaseUrl); + const now = new Date(); + + // Start of today (midnight) + const startOfToday = new Date(now); + startOfToday.setHours(0, 0, 0, 0); + + // Start of week (Monday) + 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); + + // Start of month + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + + // Query all metrics in parallel + const [totalResult, verifiedResult, todayResult, weekResult, monthResult] = await Promise.all( + [ + // Total users + db.select({ count: count() }).from(users).where(isNull(users.deletedAt)), + // Verified users + db + .select({ count: count() }) + .from(users) + .where(and(isNull(users.deletedAt), eq(users.emailVerified, true))), + // Users created today + db + .select({ count: count() }) + .from(users) + .where(and(isNull(users.deletedAt), gte(users.createdAt, startOfToday))), + // Users created this week + db + .select({ count: count() }) + .from(users) + .where(and(isNull(users.deletedAt), gte(users.createdAt, startOfWeek))), + // Users created this month + db + .select({ count: count() }) + .from(users) + .where(and(isNull(users.deletedAt), gte(users.createdAt, startOfMonth))), + ] + ); + + this.usersTotal.set(totalResult[0].count); + this.usersVerified.set(verifiedResult[0].count); + this.usersCreatedToday.set(todayResult[0].count); + this.usersCreatedThisWeek.set(weekResult[0].count); + this.usersCreatedThisMonth.set(monthResult[0].count); + } catch (error) { + this.logger.error('Failed to update user metrics:', error); + } } async getMetrics(): Promise { diff --git a/services/telegram-stats-bot/src/analytics/analytics.module.ts b/services/telegram-stats-bot/src/analytics/analytics.module.ts index 30ac251e4..116d4409c 100644 --- a/services/telegram-stats-bot/src/analytics/analytics.module.ts +++ b/services/telegram-stats-bot/src/analytics/analytics.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common'; import { UmamiModule } from '../umami/umami.module'; +import { UsersModule } from '../users/users.module'; import { AnalyticsService } from './analytics.service'; @Module({ - imports: [UmamiModule], + imports: [UmamiModule, UsersModule], providers: [AnalyticsService], exports: [AnalyticsService], }) diff --git a/services/telegram-stats-bot/src/analytics/analytics.service.ts b/services/telegram-stats-bot/src/analytics/analytics.service.ts index aafea29be..c1fd01e98 100644 --- a/services/telegram-stats-bot/src/analytics/analytics.service.ts +++ b/services/telegram-stats-bot/src/analytics/analytics.service.ts @@ -1,17 +1,22 @@ import { Injectable, Logger } from '@nestjs/common'; import { UmamiService, UmamiStats } from '../umami/umami.service'; +import { UsersService, UserStats } from '../users/users.service'; import { formatDailyReport, formatWeeklyReport, formatRealtimeReport, formatStatsOverview, + formatUsersReportCompact, } from './formatters'; @Injectable() export class AnalyticsService { private readonly logger = new Logger(AnalyticsService.name); - constructor(private readonly umamiService: UmamiService) {} + constructor( + private readonly umamiService: UmamiService, + private readonly usersService: UsersService + ) {} private getStartOfDay(date: Date = new Date()): Date { const start = new Date(date); @@ -75,7 +80,15 @@ export class AnalyticsService { async generateDailyReport(): Promise { try { const stats = await this.getTodayStats(); - return formatDailyReport(stats, new Date()); + let report = formatDailyReport(stats, new Date()); + + // Add user stats to daily report + const userStats = await this.usersService.getUserStats(); + if (userStats) { + report += formatUsersReportCompact(userStats); + } + + return report; } catch (error) { this.logger.error('Failed to generate daily report:', error); return '❌ Fehler beim Erstellen des Daily Reports'; diff --git a/services/telegram-stats-bot/src/analytics/formatters.ts b/services/telegram-stats-bot/src/analytics/formatters.ts index d8d6f0b83..89912c25f 100644 --- a/services/telegram-stats-bot/src/analytics/formatters.ts +++ b/services/telegram-stats-bot/src/analytics/formatters.ts @@ -203,27 +203,103 @@ Verfügbare Befehle: • Weekly: Jeden Montag um 9:00`; } +export interface DailyRegistration { + date: string; + count: number; +} + export interface UserStats { totalUsers: number; verifiedUsers: number; todayNewUsers: number; + yesterdayNewUsers: number; weekNewUsers: number; + lastWeekNewUsers: number; monthNewUsers: number; + dailyRegistrations: DailyRegistration[]; +} + +function createMiniBarChart(dailyRegistrations: DailyRegistration[]): string[] { + if (dailyRegistrations.length === 0) return []; + + const maxCount = Math.max(...dailyRegistrations.map((d) => d.count), 1); + const barChars = ['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + + // Fill in missing days and sort + const last7Days: DailyRegistration[] = []; + for (let i = 6; i >= 0; i--) { + const date = new Date(); + date.setDate(date.getDate() - i); + const dateStr = date.toISOString().split('T')[0]; + const found = dailyRegistrations.find((d) => d.date === dateStr); + last7Days.push({ date: dateStr, count: found?.count || 0 }); + } + + const bars = last7Days.map((d) => { + const index = Math.floor((d.count / maxCount) * (barChars.length - 1)); + return barChars[Math.max(0, index)]; + }); + + const dayLabels = last7Days.map((d) => { + const date = new Date(d.date); + return ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'][date.getDay()]; + }); + + return [`${bars.join('')}`, `${dayLabels.join('')}`]; } export function formatUsersReport(stats: UserStats): string { + const verificationRate = + stats.totalUsers > 0 ? Math.round((stats.verifiedUsers / stats.totalUsers) * 100) : 0; + + // Calculate trends + const dailyTrend = + stats.yesterdayNewUsers > 0 + ? ((stats.todayNewUsers - stats.yesterdayNewUsers) / stats.yesterdayNewUsers) * 100 + : stats.todayNewUsers > 0 + ? 100 + : 0; + + const weeklyTrend = + stats.lastWeekNewUsers > 0 + ? ((stats.weekNewUsers - stats.lastWeekNewUsers) / stats.lastWeekNewUsers) * 100 + : stats.weekNewUsers > 0 + ? 100 + : 0; + const lines: string[] = [ '👥 ManaCore User Statistics', '━━━━━━━━━━━━━━━━━━━━', '', - `👤 Gesamt: ${formatNumber(stats.totalUsers)}`, - `✅ Verifiziert: ${formatNumber(stats.verifiedUsers)}`, + '📊 Übersicht', + ` 👤 Gesamt: ${formatNumber(stats.totalUsers)}`, + ` ✅ Verifiziert: ${formatNumber(stats.verifiedUsers)} (${verificationRate}%)`, '', - '📊 Neue Registrierungen:', - ` Heute: +${formatNumber(stats.todayNewUsers)}`, - ` Diese Woche: +${formatNumber(stats.weekNewUsers)}`, + '📈 Neue Registrierungen', + ` Heute: +${formatNumber(stats.todayNewUsers)} ${formatChangeEmoji(dailyTrend)}`, + ` Gestern: +${formatNumber(stats.yesterdayNewUsers)}`, + ` Diese Woche: +${formatNumber(stats.weekNewUsers)} ${formatChange(weeklyTrend)} ${formatChangeEmoji(weeklyTrend)}`, ` Dieser Monat: +${formatNumber(stats.monthNewUsers)}`, ]; + // Add mini bar chart for last 7 days + if (stats.dailyRegistrations.length > 0) { + lines.push(''); + lines.push('📅 Letzte 7 Tage'); + lines.push(...createMiniBarChart(stats.dailyRegistrations)); + } + return lines.join('\n'); } + +export function formatUsersReportCompact(stats: UserStats): string { + const verificationRate = + stats.totalUsers > 0 ? Math.round((stats.verifiedUsers / stats.totalUsers) * 100) : 0; + + return [ + '', + '👥 Registrierte User', + ` Gesamt: ${formatNumber(stats.totalUsers)} (${verificationRate}% verifiziert)`, + ` Heute: +${formatNumber(stats.todayNewUsers)} | Woche: +${formatNumber(stats.weekNewUsers)} | Monat: +${formatNumber(stats.monthNewUsers)}`, + ].join('\n'); +} diff --git a/services/telegram-stats-bot/src/users/users.service.ts b/services/telegram-stats-bot/src/users/users.service.ts index afa4cb1bc..58b903ba8 100644 --- a/services/telegram-stats-bot/src/users/users.service.ts +++ b/services/telegram-stats-bot/src/users/users.service.ts @@ -8,6 +8,14 @@ export interface UserStats { todayNewUsers: number; weekNewUsers: number; monthNewUsers: number; + yesterdayNewUsers: number; + lastWeekNewUsers: number; + dailyRegistrations: DailyRegistration[]; +} + +export interface DailyRegistration { + date: string; + count: number; } @Injectable() @@ -43,31 +51,60 @@ export class UsersService implements OnModuleInit { const startOfToday = new Date(now); startOfToday.setHours(0, 0, 0, 0); + const startOfYesterday = new Date(startOfToday); + startOfYesterday.setDate(startOfYesterday.getDate() - 1); + 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 startOfLastWeek = new Date(startOfWeek); + startOfLastWeek.setDate(startOfLastWeek.getDate() - 7); + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + // Main stats query 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 >= ${startOfYesterday.toISOString()} AND created_at < ${startOfToday.toISOString()}) as yesterday_new_users, COUNT(*) FILTER (WHERE created_at >= ${startOfWeek.toISOString()}) as week_new_users, + COUNT(*) FILTER (WHERE created_at >= ${startOfLastWeek.toISOString()} AND created_at < ${startOfWeek.toISOString()}) as last_week_new_users, COUNT(*) FILTER (WHERE created_at >= ${startOfMonth.toISOString()}) as month_new_users FROM auth.users WHERE deleted_at IS NULL `; + // Get daily registrations for last 7 days + const dailyStats = await this.sql` + SELECT + DATE(created_at) as date, + COUNT(*) as count + FROM auth.users + WHERE deleted_at IS NULL + AND created_at >= ${new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString()} + GROUP BY DATE(created_at) + ORDER BY date DESC + `; + + const dailyRegistrations: DailyRegistration[] = dailyStats.map((row) => ({ + date: new Date(row.date).toISOString().split('T')[0], + count: Number(row.count), + })); + return { totalUsers: Number(result.total_users), verifiedUsers: Number(result.verified_users), todayNewUsers: Number(result.today_new_users), + yesterdayNewUsers: Number(result.yesterday_new_users), weekNewUsers: Number(result.week_new_users), + lastWeekNewUsers: Number(result.last_week_new_users), monthNewUsers: Number(result.month_new_users), + dailyRegistrations, }; } catch (error) { this.logger.error('Failed to fetch user stats:', error);