forked from MergeFi/backend
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathbounties.service.ts
More file actions
177 lines (156 loc) · 5.68 KB
/
Copy pathbounties.service.ts
File metadata and controls
177 lines (156 loc) · 5.68 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
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Bounty, Team, User } from '../common/entities';
import { BountyStatus } from '../common/enums';
import { assertTransition } from './bounty-state-machine';
import { EscrowService } from '../escrow/escrow.service';
import { CreateBountyDto } from './dto/create-bounty.dto';
@Injectable()
export class BountiesService {
constructor(
@InjectRepository(Bounty) private readonly bountyRepo: Repository<Bounty>,
@InjectRepository(User) private readonly userRepo: Repository<User>,
@InjectRepository(Team) private readonly teamRepo: Repository<Team>,
private readonly escrowService: EscrowService,
) {}
async create(dto: CreateBountyDto): Promise<Bounty> {
const bounty = this.bountyRepo.create({
issueId: dto.issueId,
sponsorId: dto.sponsorId,
amount: dto.amount,
asset: dto.asset,
difficulty: dto.difficulty,
deadline: dto.deadline ? new Date(dto.deadline) : null,
status: BountyStatus.OPEN,
});
return this.bountyRepo.save(bounty);
}
async findOne(id: string): Promise<Bounty> {
const bounty = await this.bountyRepo.findOne({ where: { id } });
if (!bounty) throw new NotFoundException(`Bounty ${id} not found`);
return bounty;
}
/** Sponsor funds the bounty: locks the amount in the escrow contract and moves OPEN -> FUNDED. */
async fund(id: string, funderAddress: string): Promise<Bounty> {
const bounty = await this.findOne(id);
assertTransition(bounty.status, BountyStatus.FUNDED);
const escrow = await this.escrowService.fund({
amount: bounty.amount,
asset: bounty.asset,
funderAddress,
bountyId: bounty.id,
sponsorId: bounty.sponsorId,
});
bounty.escrow = escrow;
bounty.escrowId = escrow.id;
bounty.status = BountyStatus.FUNDED;
return this.bountyRepo.save(bounty);
}
/** Contributor claims a funded bounty. */
async claim(id: string, contributorId: string): Promise<Bounty> {
const bounty = await this.findOne(id);
assertTransition(bounty.status, BountyStatus.CLAIMED);
bounty.claimedById = contributorId;
bounty.status = BountyStatus.CLAIMED;
bounty.claimedAt = new Date();
return this.bountyRepo.save(bounty);
}
/** A PR referencing the issue was opened. */
async markInReview(
id: string,
prUrl: string,
prNumber: number,
): Promise<Bounty> {
const bounty = await this.findOne(id);
assertTransition(bounty.status, BountyStatus.IN_REVIEW);
bounty.status = BountyStatus.IN_REVIEW;
bounty.prUrl = prUrl;
bounty.prNumber = prNumber;
return this.bountyRepo.save(bounty);
}
/**
* The linked PR was merged on GitHub. Transitions to MERGED and immediately
* triggers the escrow release (single recipient or team split), moving to
* PAID once the on-chain release call succeeds.
*/
async markMergedAndRelease(id: string): Promise<Bounty> {
const bounty = await this.findOne(id);
assertTransition(bounty.status, BountyStatus.MERGED);
bounty.status = BountyStatus.MERGED;
bounty.mergedAt = new Date();
await this.bountyRepo.save(bounty);
if (!bounty.escrowId) {
// No escrow was ever funded (e.g. informally tracked bounty) — nothing to release.
return bounty;
}
if (bounty.teamId) {
const team = await this.teamRepo.findOne({
where: { id: bounty.teamId },
relations: { splits: true },
});
if (team && team.splits.length > 0) {
const recipients = await Promise.all(
team.splits.map(async (split) => {
const user = await this.userRepo.findOne({
where: { id: split.userId },
});
return {
recipientId: split.userId,
recipientAddress: user?.stellarAddress ?? '',
percentage: Number(split.percentage),
};
}),
);
await this.escrowService.splitRelease(bounty.escrowId, recipients);
}
} else if (bounty.claimedById) {
const contributor = await this.userRepo.findOne({
where: { id: bounty.claimedById },
});
await this.escrowService.release(
bounty.escrowId,
contributor?.stellarAddress ?? '',
bounty.claimedById,
);
}
assertTransition(bounty.status, BountyStatus.PAID);
bounty.status = BountyStatus.PAID;
bounty.paidAt = new Date();
return this.bountyRepo.save(bounty);
}
/** Sponsor (or admin/expiry job) reclaims escrowed funds. */
async refund(id: string): Promise<Bounty> {
const bounty = await this.findOne(id);
assertTransition(bounty.status, BountyStatus.REFUNDED);
if (bounty.escrowId) {
await this.escrowService.refund(bounty.escrowId);
}
bounty.status = BountyStatus.REFUNDED;
return this.bountyRepo.save(bounty);
}
/** Marks bounties whose deadline has passed and that were never merged as expired. */
async expireOverdue(): Promise<number> {
const overdue = await this.bountyRepo
.createQueryBuilder('bounty')
.where('bounty.deadline IS NOT NULL AND bounty.deadline < :now', {
now: new Date(),
})
.andWhere('bounty.status IN (:...statuses)', {
statuses: [
BountyStatus.OPEN,
BountyStatus.FUNDED,
BountyStatus.CLAIMED,
],
})
.getMany();
for (const bounty of overdue) {
bounty.status = BountyStatus.EXPIRED;
await this.bountyRepo.save(bounty);
}
return overdue.length;
}
async list(status?: BountyStatus): Promise<Bounty[]> {
return this.bountyRepo.find({ where: status ? { status } : {} });
}
}