forked from MergeFi/backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathanalytics.service.ts
More file actions
121 lines (110 loc) · 3.86 KB
/
Copy pathanalytics.service.ts
File metadata and controls
121 lines (110 loc) · 3.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
Bounty,
Issue,
Repository as RepositoryEntity,
} from '../common/entities';
import { BountyStatus } from '../common/enums';
export interface ContributorAnalytics {
lifetimeEarnings: number;
repoCount: number;
orgCount: number;
mergeRate: number;
avgReviewTimeHours: number;
languages: Record<string, number>;
heatmap: Array<{ date: string; count: number }>;
topClients: Array<{ sponsorId: string; totalPaid: number }>;
}
@Injectable()
export class AnalyticsService {
constructor(
@InjectRepository(Bounty) private readonly bountyRepo: Repository<Bounty>,
@InjectRepository(Issue) private readonly issueRepo: Repository<Issue>,
@InjectRepository(RepositoryEntity)
private readonly repositoryRepo: Repository<RepositoryEntity>,
) {}
async forContributor(userId: string): Promise<ContributorAnalytics> {
const claimed = await this.bountyRepo.find({
where: { claimedById: userId },
});
const paid = claimed.filter((b) => b.status === BountyStatus.PAID);
const merged = claimed.filter((b) =>
[BountyStatus.MERGED, BountyStatus.PAID].includes(b.status),
);
const lifetimeEarnings = paid.reduce((sum, b) => sum + Number(b.amount), 0);
const mergeRate =
claimed.length > 0 ? (merged.length / claimed.length) * 100 : 0;
const reviewTimes = merged
.filter((b) => b.claimedAt && b.mergedAt)
.map((b) => (b.mergedAt!.getTime() - b.claimedAt!.getTime()) / 3_600_000);
const avgReviewTimeHours =
reviewTimes.length > 0
? reviewTimes.reduce((a, b) => a + b, 0) / reviewTimes.length
: 0;
const issues = claimed.length
? await this.issueRepo.find({
where: claimed.map((b) => ({ id: b.issueId })),
relations: { repository: true },
})
: [];
const repoIds = new Set(issues.map((i) => i.repositoryId));
const orgs = new Set(
issues.map((i) => i.repository?.owner).filter(Boolean),
);
const languages = issues.reduce<Record<string, number>>((acc, issue) => {
const lang = issue.repository?.primaryLanguage;
if (lang) acc[lang] = (acc[lang] ?? 0) + 1;
return acc;
}, {});
const heatmapMap = new Map<string, number>();
for (const bounty of paid) {
if (!bounty.paidAt) continue;
const day = bounty.paidAt.toISOString().slice(0, 10);
heatmapMap.set(day, (heatmapMap.get(day) ?? 0) + 1);
}
const heatmap = [...heatmapMap.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([date, count]) => ({ date, count }));
const clientTotals = new Map<string, number>();
for (const bounty of paid) {
if (!bounty.sponsorId) continue;
clientTotals.set(
bounty.sponsorId,
(clientTotals.get(bounty.sponsorId) ?? 0) + Number(bounty.amount),
);
}
const topClients = [...clientTotals.entries()]
.map(([sponsorId, totalPaid]) => ({ sponsorId, totalPaid }))
.sort((a, b) => b.totalPaid - a.totalPaid)
.slice(0, 10);
return {
lifetimeEarnings,
repoCount: repoIds.size,
orgCount: orgs.size,
mergeRate,
avgReviewTimeHours,
languages,
heatmap,
topClients,
};
}
/** Platform-wide stats for the homepage / admin view. */
async platformSummary() {
const [totalBounties, totalPaidRaw, totalRepos] = await Promise.all([
this.bountyRepo.count(),
this.bountyRepo
.createQueryBuilder('bounty')
.select('COALESCE(SUM(bounty.amount), 0)', 'total')
.where('bounty.status = :status', { status: BountyStatus.PAID })
.getRawOne<{ total: string }>(),
this.repositoryRepo.count(),
]);
return {
totalBounties,
totalPaidOut: Number(totalPaidRaw?.total ?? 0),
totalRepos,
};
}
}