Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions backend/src/admin/audit.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,10 @@ export class AuditService {
await this.submissionRepo.save(submission);
}

// Grant pipes — delta logic identical to the previous fraud poller path.
// Grant pipes — delta logic identical to the fraud poller path. Earned
// hours = project override_hours PLUS approved devlog hours (devlog hours
// count like normal hours and, like all hours, only mint pipes here at
// fraud review).
if ((project.overrideHours ?? 0) > 0) {
const totals = await this.projectRepo
.createQueryBuilder('p')
Expand All @@ -490,7 +493,17 @@ export class AuditService {
`(p.status = 'approved' OR (p.status <> 'approved' AND p.pipes_granted > 0))`,
)
.getRawOne<{ earnedHours: string; granted: string }>();
const target = Math.floor(Number(totals?.earnedHours ?? 0));
const devlogRows: Array<{ h: string }> = await this.projectRepo.manager.query(
`SELECT COALESCE(SUM(d.approved_hours), 0) AS h
FROM devlogs d
JOIN projects p ON p.id = d.project_id
WHERE p.user_id = $1
AND d.approved = true
AND (p.status = 'approved' OR (p.status <> 'approved' AND p.pipes_granted > 0))`,
[project.userId],
);
const devlogHours = Number(devlogRows?.[0]?.h ?? 0);
const target = Math.floor(Number(totals?.earnedHours ?? 0) + devlogHours);
const previouslyGranted = Number(totals?.granted ?? 0);
const delta = target - previouslyGranted;
if (delta > 0) {
Expand Down
60 changes: 33 additions & 27 deletions backend/src/devlogs/devlogs.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,43 +142,49 @@ export class DevlogsService {
return { ok: true };
}

/**
* A reviewer approves (or un-approves) a devlog and sets its hours. Devlog
* hours are treated exactly like normal project hours, but this only RECORDS
* them on the devlog — it deliberately does NOT touch `project.overrideHours`
* or mint pipes. Pipes are minted solely at fraud review by a Fraud Reviewer
* or Super Admin, where the reconcile folds approved devlog hours into the
* payout base (see FraudReviewService.completeApproval / AuditService). This
* keeps the pipe-minting gate in one fraud-gated place and avoids a reviewer
* side-channel into the balance.
*
* The devlog row is locked FOR UPDATE so two concurrent reviews of the same
* devlog can't race on its approved state.
*/
async reviewDevlog(
devlogId: string,
reviewerId: string,
dto: { approved: boolean; approvedHours: number | null }) {
const devlog = await this.devlogRepo.findOne({ where: { id: devlogId } });
if (!devlog) throw new BadRequestException('Devlog not found');
dto: { approved: boolean; approvedHours: number | null },
) {
return this.dataSource.transaction(async (manager) => {
const devlog = await manager.findOne(Devlog, {
where: { id: devlogId },
lock: { mode: 'pessimistic_write' },
});
if (!devlog) throw new BadRequestException('Devlog not found');
if (!devlog.projectId) throw new BadRequestException('Devlog is not linked to a project');

const hours =
const hours =
dto.approved && typeof dto.approvedHours === 'number' && dto.approvedHours >= 0
? Math.round(Math.min(dto.approvedHours, 1000) * 10) / 10
: 0;

const previous = devlog.approved ? devlog.approvedHours ?? 0 : 0;
const delta = Math.round((hours - previous) * 10) / 10;

return this.dataSource.transaction(async (manager) => {
if (delta != 0) {
const project = await manager.findOne(Project, { where: { id: devlog.projectId! }, lock: { mode: 'pessimistic_write' } });
if (project) {
const next = Math.round(((project.overrideHours ?? 0) + delta) * 10) / 10;
project.overrideHours = Math.max(0, next);
await manager.save(Project, project);
}
}
devlog.approved = dto.approved;
devlog.approvedHours = dto.approved ? hours : null;
devlog.approvedByReviewerId = reviewerId;
devlog.approvedAt = new Date();
await manager.save(Devlog, devlog);

await this.auditLogService.log(
reviewerId,
'devlog_reviewed',
`${dto.approved ? 'Approved' : 'Unapproved'} devlog "${devlog.title}" (${hours}h, Δ${delta}h) on project ${devlog.projectId}`,
devlog.approved = dto.approved;
devlog.approvedHours = dto.approved ? hours : null;
devlog.approvedByReviewerId = reviewerId;
devlog.approvedAt = new Date();
await manager.save(Devlog, devlog);

await this.auditLogService.log(
reviewerId,
'devlog_reviewed',
`${dto.approved ? 'Approved' : 'Unapproved'} devlog "${devlog.title}" (${dto.approved ? hours : 0}h) on project ${devlog.projectId} — pipes mint at fraud review`,
);
return { id: devlog.id, approved: devlog.approved, approvedHours: devlog.approvedHours};
return { id: devlog.id, approved: devlog.approved, approvedHours: devlog.approvedHours };
});
}

Expand Down
30 changes: 27 additions & 3 deletions backend/src/fraud-review/fraud-review.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,26 @@ export class FraudReviewService implements OnApplicationBootstrap, OnApplication
);
}

/**
* Sum of approved devlog hours across the user's "earned" projects (approved,
* or already partially paid out) — the same project set the pipe reconcile
* counts override_hours over. Kept as a separate aggregate (not a JOIN into
* the override_hours SUM) so devlog rows can't fan out and inflate the
* project-hours total.
*/
private async approvedDevlogHoursForUser(userId: string): Promise<number> {
const rows: Array<{ h: string }> = await this.projectRepo.manager.query(
`SELECT COALESCE(SUM(d.approved_hours), 0) AS h
FROM devlogs d
JOIN projects p ON p.id = d.project_id
WHERE p.user_id = $1
AND d.approved = true
AND (p.status = 'approved' OR (p.status <> 'approved' AND p.pipes_granted > 0))`,
[userId],
);
return Number(rows?.[0]?.h ?? 0);
}

private async completeApproval(row: FraudReview): Promise<void> {
const project = await this.projectRepo.findOne({
where: { id: row.projectId },
Expand All @@ -476,8 +496,11 @@ export class FraudReviewService implements OnApplicationBootstrap, OnApplication
}

// 3. Grant pipes — same delta logic as AdminService.reviewProject. Target =
// floor(sum of override_hours across the user's earned projects), delta =
// target − sum(pipes_granted) across all the user's projects.
// floor(earned hours across the user's earned projects), delta =
// target − sum(pipes_granted) across all the user's projects. Earned
// hours = project override_hours PLUS approved devlog hours: devlog hours
// count exactly like normal hours, and (like all hours) they only mint
// pipes here, at fraud review.
if ((project.overrideHours ?? 0) > 0) {
const totals = await this.projectRepo
.createQueryBuilder('p')
Expand All @@ -488,7 +511,8 @@ export class FraudReviewService implements OnApplicationBootstrap, OnApplication
`(p.status = 'approved' OR (p.status <> 'approved' AND p.pipes_granted > 0))`,
)
.getRawOne<{ earnedHours: string; granted: string }>();
const target = Math.floor(Number(totals?.earnedHours ?? 0));
const devlogHours = await this.approvedDevlogHoursForUser(project.userId);
const target = Math.floor(Number(totals?.earnedHours ?? 0) + devlogHours);
const previouslyGranted = Number(totals?.granted ?? 0);
const delta = target - previouslyGranted;
if (delta > 0) {
Expand Down