diff --git a/.env.example b/.env.example index 7913f204..385a849e 100644 --- a/.env.example +++ b/.env.example @@ -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 @@ -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. diff --git a/README.md b/README.md index 8eff283e..9dd0bb0e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/bounties/bounties.service.ts b/src/bounties/bounties.service.ts index cd166484..b1a0f8a5 100644 --- a/src/bounties/bounties.service.ts +++ b/src/bounties/bounties.service.ts @@ -67,7 +67,14 @@ export class BountiesService { } async findOne(id: string): Promise { - 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; } @@ -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 }); diff --git a/src/bounties/bounty-expiry.scheduler.ts b/src/bounties/bounty-expiry.scheduler.ts index 99363e12..58b79c91 100644 --- a/src/bounties/bounty-expiry.scheduler.ts +++ b/src/bounties/bounty-expiry.scheduler.ts @@ -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'; /** @@ -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 { - 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(() => {}); + } } } } diff --git a/src/common/idempotency/idempotency-cleanup.service.ts b/src/common/idempotency/idempotency-cleanup.service.ts index 73c08bc7..5ed8d10b 100644 --- a/src/common/idempotency/idempotency-cleanup.service.ts +++ b/src/common/idempotency/idempotency-cleanup.service.ts @@ -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'; /** @@ -18,15 +18,49 @@ export class IdempotencyCleanupService { constructor( @InjectRepository(IdempotencyKey) private readonly repo: Repository, + @Optional() private readonly dataSource?: DataSource, ) {} @Cron(CronExpression.EVERY_HOUR) async removeExpired(): Promise { - 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(() => {}); + } } } } diff --git a/src/config/configuration.ts b/src/config/configuration.ts index 40e91db2..4c3c0a50 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -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: diff --git a/src/reputation/reputation.controller.ts b/src/reputation/reputation.controller.ts index 31da4a1d..2e0adfca 100644 --- a/src/reputation/reputation.controller.ts +++ b/src/reputation/reputation.controller.ts @@ -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'; @@ -33,6 +34,7 @@ export class ReputationController { } } + @Throttle({ long: { limit: 10, ttl: 3_600_000 } }) @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Post(':userId/recompute') diff --git a/src/reputation/reputation.service.ts b/src/reputation/reputation.service.ts index 0533cd2f..6bb474ed 100644 --- a/src/reputation/reputation.service.ts +++ b/src/reputation/reputation.service.ts @@ -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),