Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@

# --- App ---------------------------------------------------------------
NODE_ENV=development
PORT=3000
PORT=4000
# Public URL of this API, used to build OAuth callback / webhook URLs
APP_URL=http://localhost:3000
APP_URL=http://localhost:4000
# Frontend URL, used for CORS + OAuth redirect back to the SPA
FRONTEND_URL=http://localhost:3001
FRONTEND_URL=http://localhost:3000

# --- Database (PostgreSQL via TypeORM) ----------------------------------
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/mergefi
Expand All @@ -31,7 +31,7 @@ JWT_EXPIRES_IN=7d
GITHUB_CLIENT_ID=mock_client_id_12345
GITHUB_CLIENT_SECRET=mock_secret_key_67890

GITHUB_OAUTH_CALLBACK_URL=http://localhost:3000/api/auth/github/callback
GITHUB_OAUTH_CALLBACK_URL=http://localhost:4000/api/auth/github/callback

# --- GitHub App / REST sync (Octokit) -------------------------------------
# Personal access token or GitHub App installation token used for repo sync.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ docker compose up --build
- **Hot Reloading**: The codebase is mounted into the container using a bind mount. File changes on the host will automatically trigger application restarts.
- **Node Modules Isolation**: The container uses an anonymous volume for `/usr/src/app/node_modules`. This prevents Windows/Host compiled node packages from contaminating the Linux-native container.
- **Services**:
- The API is served at `http://localhost:3000/api` (Swagger docs at `http://localhost:3000/api/docs`).
- The API is served at `http://localhost:4000/api` (Swagger docs at `http://localhost:4000/api/docs`).
- PostgreSQL is mapped to port `5432` on your localhost with credentials `postgres:postgres` and database name `mergefi`.

### 2. Local Development Natively on Host
Expand Down
14 changes: 12 additions & 2 deletions src/bounties/bounties.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ export class BountiesService {
}

async findOne(id: string): Promise<Bounty> {
const bounty = await this.bountyRepo.findOne({ where: { id } });
const bounty = await this.bountyRepo.findOne({
where: { id },
relations: {
issue: { repository: true },
claimedBy: true,
team: { splits: true },
},
});
if (!bounty) throw new NotFoundException(`Bounty ${id} not found`);
return bounty;
}
Expand Down Expand Up @@ -294,7 +301,10 @@ export class BountiesService {
const qb = this.bountyRepo
.createQueryBuilder('bounty')
.leftJoinAndSelect('bounty.issue', 'issue')
.leftJoinAndSelect('issue.repository', 'repository');
.leftJoinAndSelect('issue.repository', 'repository')
.leftJoinAndSelect('bounty.claimedBy', 'claimedBy')
.leftJoinAndSelect('bounty.team', 'team')
.leftJoinAndSelect('team.splits', 'splits');

if (options.status) {
qb.andWhere('bounty.status = :status', { status: options.status });
Expand Down
45 changes: 40 additions & 5 deletions src/bounties/bounty-expiry.scheduler.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Optional } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { DataSource } from 'typeorm';
import { BountiesService } from './bounties.service';

/**
Expand All @@ -18,13 +19,47 @@ import { BountiesService } from './bounties.service';
export class BountyExpiryScheduler {
private readonly logger = new Logger(BountyExpiryScheduler.name);

constructor(private readonly bountiesService: BountiesService) {}
constructor(
private readonly bountiesService: BountiesService,
@Optional() private readonly dataSource?: DataSource,
) {}

@Cron(CronExpression.EVERY_HOUR)
async sweepOverdueBounties(): Promise<void> {
const expired = await this.bountiesService.expireOverdue();
if (expired > 0) {
this.logger.log(`Expired ${expired} overdue bounty(ies)`);
if (process.env.RUN_SCHEDULERS === 'false') {
return;
}

const lockId = 847291;
let lockAcquired = true;
if (this.dataSource?.isInitialized) {
try {
const res = await this.dataSource.query(
'SELECT pg_try_advisory_lock($1) as acquired',
[lockId],
);
lockAcquired = res?.[0]?.acquired ?? true;
} catch {
lockAcquired = true;
}
}

if (!lockAcquired) {
this.logger.debug('Skipping sweep: another instance holds advisory lock');
return;
}

try {
const expired = await this.bountiesService.expireOverdue();
if (expired > 0) {
this.logger.log(`Expired ${expired} overdue bounty(ies)`);
}
} finally {
if (this.dataSource?.isInitialized && lockAcquired) {
await this.dataSource
.query('SELECT pg_advisory_unlock($1)', [lockId])
.catch(() => {});
}
}
}
}
48 changes: 41 additions & 7 deletions src/common/idempotency/idempotency-cleanup.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable, Logger, Optional } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { LessThan, Repository } from 'typeorm';
import { DataSource, LessThan, Repository } from 'typeorm';
import { IdempotencyKey } from '../entities/idempotency-key.entity';

/**
Expand All @@ -18,15 +18,49 @@ export class IdempotencyCleanupService {
constructor(
@InjectRepository(IdempotencyKey)
private readonly repo: Repository<IdempotencyKey>,
@Optional() private readonly dataSource?: DataSource,
) {}

@Cron(CronExpression.EVERY_HOUR)
async removeExpired(): Promise<void> {
const result = await this.repo.delete({
expiresAt: LessThan(new Date()),
});
if (result.affected) {
this.logger.log(`Removed ${result.affected} expired idempotency key(s)`);
if (process.env.RUN_SCHEDULERS === 'false') {
return;
}

const lockId = 847292;
let lockAcquired = true;
if (this.dataSource?.isInitialized) {
try {
const res = await this.dataSource.query(
'SELECT pg_try_advisory_lock($1) as acquired',
[lockId],
);
lockAcquired = res?.[0]?.acquired ?? true;
} catch {
lockAcquired = true;
}
}

if (!lockAcquired) {
this.logger.debug(
'Skipping cleanup: another instance holds advisory lock',
);
return;
}

try {
const result = await this.repo.delete({
expiresAt: LessThan(new Date()),
});
if (result.affected) {
this.logger.log(`Removed ${result.affected} expired idempotency key(s)`);
}
} finally {
if (this.dataSource?.isInitialized && lockAcquired) {
await this.dataSource
.query('SELECT pg_advisory_unlock($1)', [lockId])
.catch(() => {});
}
}
}
}
6 changes: 3 additions & 3 deletions src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ export interface AppConfig {

export default (): AppConfig => ({
env: process.env.NODE_ENV ?? 'development',
port: parseInt(process.env.PORT ?? '3000', 10),
appUrl: process.env.APP_URL ?? 'http://localhost:3000',
frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3001',
port: parseInt(process.env.PORT ?? '4000', 10),
appUrl: process.env.APP_URL ?? 'http://localhost:4000',
frontendUrl: process.env.FRONTEND_URL ?? 'http://localhost:3000',
logLevel: process.env.LOG_LEVEL ?? 'debug',
database: {
url:
Expand Down
2 changes: 2 additions & 0 deletions src/reputation/reputation.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiQuery, ApiTags } from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { ReputationService } from './reputation.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { CurrentUser } from '../common/decorators/current-user.decorator';
Expand All @@ -33,6 +34,7 @@ export class ReputationController {
}
}

@Throttle({ long: { limit: 10, ttl: 3_600_000 } })
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Post(':userId/recompute')
Expand Down
15 changes: 15 additions & 0 deletions src/reputation/reputation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,21 @@ export class ReputationService {
this.paymentRepo,
);

const latest = await this.getLatest(userId);
if (
latest &&
latest.totalEarnings === stats.lifetimeEarnings.toFixed(7) &&
latest.mergedPrCount === stats.mergedCount &&
latest.openBountiesClaimed === stats.openBountiesClaimed &&
latest.completionRate === stats.completionRate.toFixed(2) &&
latest.avgReviewTimeHours === stats.avgReviewTimeHours.toFixed(2) &&
latest.onTimeDeliveryPercentage === stats.onTimeDeliveryPercentage.toFixed(2) &&
JSON.stringify(latest.languages) === JSON.stringify(stats.languages) &&
JSON.stringify(latest.orgsContributedTo) === JSON.stringify(stats.orgs)
) {
return latest;
}

const snapshot = this.snapshotRepo.create({
userId,
totalEarnings: stats.lifetimeEarnings.toFixed(7),
Expand Down