From a76bc9d9387bfc6428fd163f4e91a44ba9755d49 Mon Sep 17 00:00:00 2001 From: patopatrish Date: Tue, 30 Jun 2026 01:09:07 +0100 Subject: [PATCH] feat(backend): Build escrow analytics aggregation service (#424) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements comprehensive admin analytics with caching across four endpoints: - GET /admin/analytics/summary — combined volume/activity/performance/user/dispute metrics (5 min cache) - GET /admin/analytics/volume — time-series with chart-friendly labels+values (15 min cache) - GET /admin/analytics/users — total/active(30d)/new(7/30/90d) metrics + weekly active 12w chart (5 min cache) - GET /admin/analytics/disputes — dispute rate, resolution rate, win rate, avg resolution time (5 min cache) Key additions: - VolumeMetrics: total, average, median escrow amounts - ActivityMetrics: counts per EscrowStatus (created/active/completed/disputed/cancelled/expired) - PerformanceMetrics: avg days to funding, completion, and dispute resolution - UserMetrics: total, active30d, new7d/30d/90d users - DisputeMetrics: dispute rate, resolution rate, win rate, outcome distribution - ChartData: { labels, values } format for all time-series responses - In-memory cache: 5 min TTL for summaries, 15 min TTL for time-series - Cache-Control response headers on all endpoints - Date range filtering support on volume endpoints Co-Authored-By: Claude Sonnet 4.6 --- .../admin/controllers/analytics.controller.ts | 50 +- .../admin/services/analytics.service.ts | 491 +++++++++++++----- 2 files changed, 400 insertions(+), 141 deletions(-) diff --git a/apps/backend/src/modules/admin/controllers/analytics.controller.ts b/apps/backend/src/modules/admin/controllers/analytics.controller.ts index a924bedf..0fb92fec 100644 --- a/apps/backend/src/modules/admin/controllers/analytics.controller.ts +++ b/apps/backend/src/modules/admin/controllers/analytics.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth } from '@nestjs/swagger'; +import { Controller, Get, Query, Res, UseGuards } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; +import { Response } from 'express'; import { AuthGuard } from '../../auth/middleware/auth.guard'; import { AdminGuard } from '../../auth/middleware/admin.guard'; import { AnalyticsService } from '../services/analytics.service'; @@ -11,31 +12,60 @@ import { AnalyticsService } from '../services/analytics.service'; export class AnalyticsController { constructor(private readonly analyticsService: AnalyticsService) {} - @Get('overview') - @ApiOperation({ summary: 'Get high-level platform analytics overview' }) - async getOverview() { - return this.analyticsService.getOverview(); + @Get('summary') + @ApiOperation({ + summary: 'Get full analytics summary (volume, activity, performance, users, disputes)', + }) + async getSummary(@Res({ passthrough: true }) res: Response) { + res.setHeader('Cache-Control', 'public, max-age=300'); + return this.analyticsService.getSummary(); } @Get('volume') @ApiOperation({ summary: 'Get escrow volume time-series data' }) + @ApiQuery({ name: 'period', required: false, enum: ['daily', 'weekly', 'monthly'] }) + @ApiQuery({ name: 'from', required: false, type: String }) + @ApiQuery({ name: 'to', required: false, type: String }) async getVolume( + @Res({ passthrough: true }) res: Response, @Query('period') period: 'daily' | 'weekly' | 'monthly' = 'daily', @Query('from') from?: string, @Query('to') to?: string, ) { - return this.analyticsService.getVolumeStats(period, from, to); + res.setHeader('Cache-Control', 'public, max-age=900'); + const [series, timeSeries] = await Promise.all([ + this.analyticsService.getVolumeStats(period, from, to), + this.analyticsService.getVolumeTimeSeries(from, to), + ]); + return { series, charts: timeSeries }; + } + + @Get('users') + @ApiOperation({ summary: 'Get user metrics and weekly active user time-series' }) + async getUsers(@Res({ passthrough: true }) res: Response) { + res.setHeader('Cache-Control', 'public, max-age=300'); + const [metrics, timeSeries] = await Promise.all([ + this.analyticsService.getUserMetrics(), + this.analyticsService.getUserTimeSeries(), + ]); + return { metrics, charts: timeSeries }; } @Get('disputes') - @ApiOperation({ summary: 'Get dispute-related metrics' }) - async getDisputes() { + @ApiOperation({ summary: 'Get dispute metrics including resolution and win rates' }) + async getDisputes(@Res({ passthrough: true }) res: Response) { + res.setHeader('Cache-Control', 'public, max-age=300'); return this.analyticsService.getDisputeMetrics(); } @Get('top-users') @ApiOperation({ summary: 'Get leaderboard of top users by volume' }) - async getTopUsers(@Query('limit') limit: string = '10') { + @ApiQuery({ name: 'limit', required: false, type: Number }) + async getTopUsers( + @Res({ passthrough: true }) res: Response, + @Query('limit') limit: string = '10', + ) { + res.setHeader('Cache-Control', 'public, max-age=300'); return this.analyticsService.getTopUsers(parseInt(limit, 10)); } } diff --git a/apps/backend/src/modules/admin/services/analytics.service.ts b/apps/backend/src/modules/admin/services/analytics.service.ts index eb8826d3..3d060c18 100644 --- a/apps/backend/src/modules/admin/services/analytics.service.ts +++ b/apps/backend/src/modules/admin/services/analytics.service.ts @@ -1,35 +1,80 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, MoreThan } from 'typeorm'; import { Escrow, EscrowStatus } from '../../escrow/entities/escrow.entity'; -import { Dispute, DisputeStatus } from '../../escrow/entities/dispute.entity'; +import { Dispute, DisputeStatus, DisputeOutcome } from '../../escrow/entities/dispute.entity'; import { User } from '../../user/entities/user.entity'; -export interface AnalyticsOverview { - escrows: Record; - volume: { - totalCompleted: number; - platformFeesCollected: number; - }; - users: { - activeLast30Days: number; - newLast30Days: number; - }; +export interface ChartData { + labels: string[]; + values: number[]; } -export interface VolumeStat { - period: string; - count: number; - volume: number; +export interface VolumeMetrics { + totalVolume: number; + averageAmount: number; + medianAmount: number; +} + +export interface ActivityMetrics { + created: number; + active: number; + completed: number; + disputed: number; + cancelled: number; + refunded: number; + expired: number; + total: number; +} + +export interface PerformanceMetrics { + avgTimeToFundingDays: number; + avgTimeToCompletionDays: number; + avgDisputeResolutionDays: number; +} + +export interface UserMetrics { + total: number; + active30d: number; + new7d: number; + new30d: number; + new90d: number; } export interface DisputeMetrics { totalDisputes: number; disputeRate: number; + resolutionRate: number; avgResolutionTimeDays: number; + winRate: number; outcomeDistribution: Record; } +export interface SummaryAnalytics { + volume: VolumeMetrics; + activity: ActivityMetrics; + performance: PerformanceMetrics; + users: UserMetrics; + disputes: DisputeMetrics; + generatedAt: string; +} + +export interface VolumeStat { + period: string; + count: number; + volume: number; +} + +export interface VolumeTimeSeries { + daily30d: ChartData; + daily90d: ChartData; + monthly12m: ChartData; +} + +export interface UserTimeSeries { + weeklyActive12w: ChartData; +} + export interface TopUser { walletAddress: string; escrowCount: number; @@ -39,9 +84,9 @@ export interface TopUser { @Injectable() export class AnalyticsService { - private readonly logger = new Logger(AnalyticsService.name); private cache = new Map(); - private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes + private readonly SUMMARY_TTL = 5 * 60 * 1000; + private readonly TIMESERIES_TTL = 15 * 60 * 1000; constructor( @InjectRepository(Escrow) @@ -52,133 +97,192 @@ export class AnalyticsService { private userRepository: Repository, ) {} - private getFromCache(key: string): unknown { + private getFromCache(key: string): T | null { const cached = this.cache.get(key); if (cached && cached.expiry > Date.now()) { - return cached.data; + return cached.data as T; } return null; } - private setCache(key: string, data: unknown): void { - this.cache.set(key, { - data, - expiry: Date.now() + this.CACHE_TTL, - }); + private setCache(key: string, data: unknown, ttl: number): void { + this.cache.set(key, { data, expiry: Date.now() + ttl }); } - async getOverview(): Promise { - const cacheKey = 'analytics_overview'; - const cached = this.getFromCache(cacheKey); - if (cached) return cached as AnalyticsOverview; + private daysAgo(days: number): Date { + const d = new Date(); + d.setDate(d.getDate() - days); + return d; + } - const last30Days = new Date(); - last30Days.setDate(last30Days.getDate() - 30); + async getSummary(): Promise { + const cacheKey = 'analytics_summary'; + const cached = this.getFromCache(cacheKey); + if (cached) return cached; - const [escrowsByStatus, totalVolumeRaw, activeUsersCount, newUsersCount] = - await Promise.all([ - this.escrowRepository - .createQueryBuilder('escrow') - .select('escrow.status', 'status') - .addSelect('COUNT(*)', 'count') - .groupBy('escrow.status') - .getRawMany<{ status: string; count: string }>(), - this.escrowRepository - .createQueryBuilder('escrow') - .select('SUM(escrow.amount)', 'total') - .where('escrow.status = :status', { status: EscrowStatus.COMPLETED }) - .getRawOne<{ total: string | null }>(), - this.userRepository.count({ - where: { updatedAt: MoreThan(last30Days) }, - }), - this.userRepository.count({ - where: { createdAt: MoreThan(last30Days) }, - }), - ]); + const [volume, activity, performance, users, disputes] = await Promise.all([ + this.getVolumeMetrics(), + this.getActivityMetrics(), + this.getPerformanceMetrics(), + this.getUserMetrics(), + this.getDisputeMetrics(), + ]); - const totalVolume = parseFloat(totalVolumeRaw?.total || '0'); - const platformFees = totalVolume * 0.01; // Assuming 1% platform fee - - const stats = { - escrows: escrowsByStatus.reduce((acc: Record, curr) => { - acc[curr.status] = parseInt(curr.count); - return acc; - }, {}), - volume: { - totalCompleted: totalVolume, - platformFeesCollected: platformFees, - }, - users: { - activeLast30Days: activeUsersCount, - newLast30Days: newUsersCount, - }, + const summary: SummaryAnalytics = { + volume, + activity, + performance, + users, + disputes, + generatedAt: new Date().toISOString(), }; - this.setCache(cacheKey, stats); - return stats; + this.setCache(cacheKey, summary, this.SUMMARY_TTL); + return summary; } - async getVolumeStats( - period: 'daily' | 'weekly' | 'monthly', - from?: string, - to?: string, - ): Promise { - const cacheKey = `analytics_volume_${period}_${from}_${to}`; - const cached = this.getFromCache(cacheKey); - if (cached) return cached as VolumeStat[]; - - let dateFormat: string; - switch (period) { - case 'daily': - dateFormat = '%Y-%m-%d'; - break; - case 'weekly': - dateFormat = '%Y-%W'; - break; - case 'monthly': - dateFormat = '%Y-%m'; - break; - default: - dateFormat = '%Y-%m-%d'; + async getVolumeMetrics(): Promise { + const cacheKey = 'analytics_volume_metrics'; + const cached = this.getFromCache(cacheKey); + if (cached) return cached; + + const rows = await this.escrowRepository + .createQueryBuilder('escrow') + .select('escrow.amount', 'amount') + .where('escrow.status = :status', { status: EscrowStatus.COMPLETED }) + .getRawMany<{ amount: string }>(); + + const amounts = rows.map((r) => parseFloat(r.amount || '0')).sort((a, b) => a - b); + const total = amounts.reduce((sum, v) => sum + v, 0); + const avg = amounts.length > 0 ? total / amounts.length : 0; + + let median = 0; + if (amounts.length > 0) { + const mid = Math.floor(amounts.length / 2); + median = + amounts.length % 2 !== 0 + ? amounts[mid] + : (amounts[mid - 1] + amounts[mid]) / 2; } - const query = this.escrowRepository + const metrics: VolumeMetrics = { + totalVolume: parseFloat(total.toFixed(7)), + averageAmount: parseFloat(avg.toFixed(7)), + medianAmount: parseFloat(median.toFixed(7)), + }; + + this.setCache(cacheKey, metrics, this.SUMMARY_TTL); + return metrics; + } + + async getActivityMetrics(): Promise { + const cacheKey = 'analytics_activity'; + const cached = this.getFromCache(cacheKey); + if (cached) return cached; + + const rows = await this.escrowRepository .createQueryBuilder('escrow') - .select(`strftime('${dateFormat}', escrow.createdAt)`, 'bucket') + .select('escrow.status', 'status') .addSelect('COUNT(*)', 'count') - .addSelect('SUM(escrow.amount)', 'volume') - .groupBy('bucket') - .orderBy('bucket', 'ASC'); + .groupBy('escrow.status') + .getRawMany<{ status: string; count: string }>(); - if (from && to) { - query.where('escrow.createdAt BETWEEN :from AND :to', { from, to }); - } + const counts = rows.reduce>((acc, r) => { + acc[r.status] = parseInt(r.count); + return acc; + }, {}); - const results = await query.getRawMany<{ - bucket: string; - count: string; - volume: string | null; - }>(); + const metrics: ActivityMetrics = { + created: counts[EscrowStatus.PENDING] ?? 0, + active: counts[EscrowStatus.ACTIVE] ?? 0, + completed: counts[EscrowStatus.COMPLETED] ?? 0, + disputed: counts[EscrowStatus.DISPUTED] ?? 0, + cancelled: counts[EscrowStatus.CANCELLED] ?? 0, + refunded: counts[EscrowStatus.CANCELLED] ?? 0, + expired: counts[EscrowStatus.EXPIRED] ?? 0, + total: rows.reduce((sum, r) => sum + parseInt(r.count), 0), + }; - const stats = results.map((r) => ({ - period: r.bucket, - count: parseInt(r.count), - volume: parseFloat(r.volume || '0'), - })); + this.setCache(cacheKey, metrics, this.SUMMARY_TTL); + return metrics; + } - this.setCache(cacheKey, stats); - return stats; + async getPerformanceMetrics(): Promise { + const cacheKey = 'analytics_performance'; + const cached = this.getFromCache(cacheKey); + if (cached) return cached; + + const [fundingRaw, completionRaw, disputeRaw] = await Promise.all([ + this.escrowRepository + .createQueryBuilder('escrow') + .select( + 'AVG(julianday(escrow.fundedAt) - julianday(escrow.createdAt))', + 'avgDays', + ) + .where('escrow.fundedAt IS NOT NULL') + .getRawOne<{ avgDays: string | null }>(), + this.escrowRepository + .createQueryBuilder('escrow') + .select( + 'AVG(julianday(escrow.updatedAt) - julianday(escrow.createdAt))', + 'avgDays', + ) + .where('escrow.status = :status', { status: EscrowStatus.COMPLETED }) + .getRawOne<{ avgDays: string | null }>(), + this.disputeRepository + .createQueryBuilder('dispute') + .select( + 'AVG(julianday(dispute.resolvedAt) - julianday(dispute.createdAt))', + 'avgDays', + ) + .where('dispute.status = :status', { status: DisputeStatus.RESOLVED }) + .getRawOne<{ avgDays: string | null }>(), + ]); + + const metrics: PerformanceMetrics = { + avgTimeToFundingDays: parseFloat( + parseFloat(fundingRaw?.avgDays || '0').toFixed(2), + ), + avgTimeToCompletionDays: parseFloat( + parseFloat(completionRaw?.avgDays || '0').toFixed(2), + ), + avgDisputeResolutionDays: parseFloat( + parseFloat(disputeRaw?.avgDays || '0').toFixed(2), + ), + }; + + this.setCache(cacheKey, metrics, this.SUMMARY_TTL); + return metrics; + } + + async getUserMetrics(): Promise { + const cacheKey = 'analytics_users'; + const cached = this.getFromCache(cacheKey); + if (cached) return cached; + + const [total, active30d, new7d, new30d, new90d] = await Promise.all([ + this.userRepository.count(), + this.userRepository.count({ where: { updatedAt: MoreThan(this.daysAgo(30)) } }), + this.userRepository.count({ where: { createdAt: MoreThan(this.daysAgo(7)) } }), + this.userRepository.count({ where: { createdAt: MoreThan(this.daysAgo(30)) } }), + this.userRepository.count({ where: { createdAt: MoreThan(this.daysAgo(90)) } }), + ]); + + const metrics: UserMetrics = { total, active30d, new7d, new30d, new90d }; + this.setCache(cacheKey, metrics, this.SUMMARY_TTL); + return metrics; } async getDisputeMetrics(): Promise { const cacheKey = 'analytics_disputes'; - const cached = this.getFromCache(cacheKey); - if (cached) return cached as DisputeMetrics; + const cached = this.getFromCache(cacheKey); + if (cached) return cached; - const [totalEscrows, totalDisputes, outcomes, avgResolutionRaw] = + const [totalEscrows, totalDisputes, resolvedCount, outcomes, avgResolutionRaw, buyerWins] = await Promise.all([ this.escrowRepository.count(), this.disputeRepository.count(), + this.disputeRepository.count({ where: { status: DisputeStatus.RESOLVED } }), this.disputeRepository .createQueryBuilder('dispute') .select('dispute.outcome', 'outcome') @@ -194,38 +298,131 @@ export class AnalyticsService { ) .where('dispute.status = :status', { status: DisputeStatus.RESOLVED }) .getRawOne<{ avgDays: string | null }>(), + this.disputeRepository.count({ + where: { status: DisputeStatus.RESOLVED, outcome: DisputeOutcome.REFUNDED_TO_BUYER }, + }), ]); - const disputeRate = - totalEscrows > 0 ? (totalDisputes / totalEscrows) * 100 : 0; + const disputeRate = totalEscrows > 0 ? (totalDisputes / totalEscrows) * 100 : 0; + const resolutionRate = totalDisputes > 0 ? (resolvedCount / totalDisputes) * 100 : 0; + const winRate = resolvedCount > 0 ? (buyerWins / resolvedCount) * 100 : 0; - const stats = { + const metrics: DisputeMetrics = { totalDisputes, disputeRate: parseFloat(disputeRate.toFixed(2)), + resolutionRate: parseFloat(resolutionRate.toFixed(2)), avgResolutionTimeDays: parseFloat( parseFloat(avgResolutionRaw?.avgDays || '0').toFixed(2), ), - outcomeDistribution: outcomes.reduce( - (acc: Record, curr) => { - if (curr.outcome) { - acc[curr.outcome] = parseInt(curr.count); - } + winRate: parseFloat(winRate.toFixed(2)), + outcomeDistribution: outcomes.reduce>( + (acc, curr) => { + if (curr.outcome) acc[curr.outcome] = parseInt(curr.count); return acc; }, {}, ), }; - this.setCache(cacheKey, stats); + this.setCache(cacheKey, metrics, this.SUMMARY_TTL); + return metrics; + } + + async getVolumeTimeSeries(from?: string, to?: string): Promise { + const cacheKey = `analytics_volume_ts_${from}_${to}`; + const cached = this.getFromCache(cacheKey); + if (cached) return cached; + + const [daily30, daily90, monthly12] = await Promise.all([ + this.queryVolumeSeries('%Y-%m-%d', this.daysAgo(30), from, to), + this.queryVolumeSeries('%Y-%m-%d', this.daysAgo(90), from, to), + this.queryVolumeSeries('%Y-%m', this.daysAgo(365), from, to), + ]); + + const result: VolumeTimeSeries = { + daily30d: this.toChartData(daily30), + daily90d: this.toChartData(daily90), + monthly12m: this.toChartData(monthly12), + }; + + this.setCache(cacheKey, result, this.TIMESERIES_TTL); + return result; + } + + async getUserTimeSeries(): Promise { + const cacheKey = 'analytics_user_ts'; + const cached = this.getFromCache(cacheKey); + if (cached) return cached; + + const weeks = await this.userRepository + .createQueryBuilder('user') + .select("strftime('%Y-%W', user.updatedAt)", 'week') + .addSelect('COUNT(DISTINCT user.id)', 'count') + .where('user.updatedAt > :since', { since: this.daysAgo(84) }) + .groupBy('week') + .orderBy('week', 'ASC') + .getRawMany<{ week: string; count: string }>(); + + const result: UserTimeSeries = { + weeklyActive12w: { + labels: weeks.map((w) => w.week), + values: weeks.map((w) => parseInt(w.count)), + }, + }; + + this.setCache(cacheKey, result, this.TIMESERIES_TTL); + return result; + } + + async getVolumeStats( + period: 'daily' | 'weekly' | 'monthly', + from?: string, + to?: string, + ): Promise { + const cacheKey = `analytics_volume_${period}_${from}_${to}`; + const cached = this.getFromCache(cacheKey); + if (cached) return cached; + + const formatMap: Record = { + daily: '%Y-%m-%d', + weekly: '%Y-%W', + monthly: '%Y-%m', + }; + const dateFormat = formatMap[period] ?? '%Y-%m-%d'; + + const query = this.escrowRepository + .createQueryBuilder('escrow') + .select(`strftime('${dateFormat}', escrow.createdAt)`, 'bucket') + .addSelect('COUNT(*)', 'count') + .addSelect('SUM(escrow.amount)', 'volume') + .groupBy('bucket') + .orderBy('bucket', 'ASC'); + + if (from && to) { + query.where('escrow.createdAt BETWEEN :from AND :to', { from, to }); + } + + const results = await query.getRawMany<{ + bucket: string; + count: string; + volume: string | null; + }>(); + + const stats = results.map((r) => ({ + period: r.bucket, + count: parseInt(r.count), + volume: parseFloat(r.volume || '0'), + })); + + this.setCache(cacheKey, stats, this.TIMESERIES_TTL); return stats; } async getTopUsers(limit: number = 10): Promise { const cacheKey = `analytics_top_users_${limit}`; - const cached = this.getFromCache(cacheKey); - if (cached) return cached as TopUser[]; + const cached = this.getFromCache(cacheKey); + if (cached) return cached; - // Use QueryBuilder to join Escrow and Party to find top users by volume const topUsers = await this.userRepository .createQueryBuilder('user') .leftJoin('escrow_parties', 'party', 'party.userId = user.id') @@ -234,7 +431,7 @@ export class AnalyticsService { .addSelect('COUNT(DISTINCT escrow.id)', 'escrowCount') .addSelect('SUM(escrow.amount)', 'totalVolume') .addSelect( - 'SUM(CASE WHEN escrow.status = :completed THEN 1 ELSE 0 END) * 1.0 / COUNT(escrow.id)', + 'SUM(CASE WHEN escrow.status = :completed THEN 1 ELSE 0 END) * 1.0 / NULLIF(COUNT(escrow.id), 0)', 'completionRate', ) .setParameter('completed', EscrowStatus.COMPLETED) @@ -252,12 +449,44 @@ export class AnalyticsService { walletAddress: u.walletAddress, escrowCount: parseInt(u.escrowCount), totalVolume: parseFloat(u.totalVolume || '0'), - completionRate: parseFloat( - parseFloat(u.completionRate || '0').toFixed(2), - ), + completionRate: parseFloat(parseFloat(u.completionRate || '0').toFixed(4)), })); - this.setCache(cacheKey, stats); + this.setCache(cacheKey, stats, this.SUMMARY_TTL); return stats; } + + // Keep legacy method for backwards compat + async getOverview() { + return this.getSummary(); + } + + private async queryVolumeSeries( + fmt: string, + since: Date, + from?: string, + to?: string, + ) { + const query = this.escrowRepository + .createQueryBuilder('escrow') + .select(`strftime('${fmt}', escrow.createdAt)`, 'bucket') + .addSelect('SUM(escrow.amount)', 'volume') + .groupBy('bucket') + .orderBy('bucket', 'ASC'); + + if (from && to) { + query.where('escrow.createdAt BETWEEN :from AND :to', { from, to }); + } else { + query.where('escrow.createdAt >= :since', { since: since.toISOString() }); + } + + return query.getRawMany<{ bucket: string; volume: string | null }>(); + } + + private toChartData(rows: { bucket: string; volume: string | null }[]): ChartData { + return { + labels: rows.map((r) => r.bucket), + values: rows.map((r) => parseFloat(r.volume || '0')), + }; + } }