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
13 changes: 13 additions & 0 deletions .cursor/mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
"headers": {
"CONTEXT7_API_KEY": "ctx7sk-4bace2c3-309e-4156-b36b-8fc75ab15a79"
},
"enabled": true
}
}
}
9 changes: 9 additions & 0 deletions spec/constitution/mission.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Mission: MergeFi Backend

MergeFi aims to bridge the gap between open-source contribution and decentralized finance by providing a robust, secure, and transparent platform for bounty management, escrow services, and reputation-based incentives.

Our backend serves as the core orchestrator, facilitating:
- Synchronizing GitHub issues and events.
- Managing bounty lifecycles.
- Ensuring trust through escrow and idempotency mechanisms.
- Empowering collaborative team development on open-source projects.
24 changes: 24 additions & 0 deletions spec/constitution/roadmap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Roadmap: MergeFi Backend

## Phase 1: Foundation (In Progress)
- [x] Project scaffolding & NestJS setup.
- [x] TypeORM and PostgreSQL integration.
- [x] Core authentication (GitHub OAuth & JWT).
- [ ] Database migration management improvements.

## Phase 2: Core Platform Features
- [ ] Implement robust Bounty State Machine.
- [ ] Escrow service integration with Stellar SDK.
- [ ] GitHub webhook integration for automated issue/PR tracking.
- [ ] Idempotency middleware for critical financial operations.

## Phase 3: Advanced Features & Scaling
- [ ] Reputation system overhaul based on contribution metrics.
- [ ] Team management and revenue splitting logic.
- [ ] Analytics service for bounty trends and ecosystem health.
- [ ] Maintenance pool management features.

## Phase 4: Production Readiness
- [ ] Full E2E test coverage for critical paths.
- [ ] Performance optimization (caching, query optimization).
- [ ] CI/CD pipeline improvements for automated deployments.
23 changes: 23 additions & 0 deletions spec/constitution/tech-stack.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Technical Stack - MergeFi Backend

## Core Framework
- **Framework**: [NestJS](https://nestjs.com/)
- **Language**: TypeScript

## Database & Persistence
- **ORM**: [TypeORM](https://typeorm.io/)
- **Database**: PostgreSQL
- **Migrations**: TypeORM Migrations

## Authentication & Security
- **Auth Provider**: Passport.js
- **Strategies**: JWT, GitHub OAuth
- **Security**: Helmet, Throttler

## External Integrations
- **GitHub**: @octokit/rest
- **Blockchain**: @stellar/stellar-sdk

## Testing
- **Unit/Integration**: Jest
- **E2E**: Supertest
29 changes: 29 additions & 0 deletions spec/features/issue-58-team-member-split-fk/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Plan: Fix TeamMemberSplit FK Integrity (Issue #58)

## Overview
Change `TeamMemberSplit.user` relation from `onDelete: 'CASCADE'` to `onDelete: 'RESTRICT'` to prevent silent deletion of financial splits when a user account is deleted, which currently causes stuck bounties.

## Architectural Changes
1. **Entity Update**: Modify `src/common/entities/team-member-split.entity.ts` to change `onDelete` to `RESTRICT`.
2. **Migration**: Create a new TypeORM migration to update the foreign key constraint.
- Drop the existing constraint (`FK_...`).
- Re-create the constraint with `ON DELETE RESTRICT`.
- Reference `1784272650000-EscrowFkIntegrityAndSponsorId.ts` for the established migration pattern.

## Data Flow Implications
- **Delete Operation**: Attempting to delete a `User` referenced by a `TeamMemberSplit` will now throw a Database Foreign Key Violation exception.
- **UX/Business Logic**: This *will* block user deletion if they are still part of an active team.
- **Future Consideration (Soft Delete)**: Explicitly note in the PR that `RESTRICT` is a safe first step to ensure data integrity. A separate feature for soft-deletion/deactivation of team membership should be scoped later to support clean account closures.

## Risks
- **Blocking User Deletion**: Legitimate account deletions may fail. This is intentional to prevent broken financial states, but requires documentation.
- **Application Error Handling**: The application should catch the DB constraint violation and present a user-friendly error (e.g., "Cannot delete user, still part of an active team").

## Verification Plan
1. **Reproduction Test**: Create a test case based on the plan in `spec.md`:
- Create team + splits (sum 100%).
- Fund bounty.
- Attempt `userRepo.delete(memberId)`.
- Verify error thrown (Database restriction).
2. **Bounty Integrity**: Verify that even if the delete attempt is made, the bounty status remains manageable (not silent data loss).
3. **Migration Test**: Ensure the migration applies and reverses correctly.
66 changes: 66 additions & 0 deletions spec/features/issue-58-team-member-split-fk/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
## Overview

`TeamMemberSplit.user` cascades on delete, unlike the careful `RESTRICT`/`SET NULL` treatment every other user-linked financial relation in this schema received:

```ts
// src/common/entities/team-member-split.entity.ts:24-29
@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn()
user: User;

@Column()
userId: string;
```

Compare to `Bounty.claimedBy`/`Bounty.sponsor`/`Bounty.team` (all `onDelete: 'SET NULL'`, `bounty.entity.ts:29-46`) and `Payment.recipient` (`onDelete: 'SET NULL'`, `payment.entity.ts:32-34`) — every other place a `User` is referenced from a money-relevant row, deleting that `User` leaves the referencing row intact with the FK nulled out, exactly the principle this schema's own FK-hardening migration established for `Escrow`/`Payment` (`1784272650000-EscrowFkIntegrityAndSponsorId.ts`). `TeamMemberSplit` is the one place that principle wasn't applied: deleting a `User` row **deletes their `TeamMemberSplit` row outright**, silently shrinking the team's composition.

The consequence: `TeamMemberSplit.percentage` values are only meaningful as a set — `team-split.util.ts`'s `validateSplitPercentages` requires them to sum to exactly 100 at *creation* time (`team-split.util.ts:8-23`), but nothing re-validates that invariant later, and nothing needs to, as long as the set of rows never changes after creation. The `CASCADE` breaks that assumption: if any team member's `User` row is ever deleted (account closure, GDPR-style deletion request, an admin cleanup, a future account-merge feature) after the team was formed, their `TeamMemberSplit` row disappears with them, and the remaining splits no longer sum to 100.

Trace what happens the next time that team gets paid. `BountiesService.markMergedAndRelease` loads `team.splits` fresh at merge time (`bounties.service.ts:101-119`) and passes them straight to `EscrowService.splitRelease`, which calls `assertValidSplits` (`escrow.service.ts:269-284`) before doing anything else:

```ts
// src/escrow/escrow.service.ts:275-279
const total = recipients.reduce((sum, r) => sum + r.percentage, 0);
if (Math.abs(total - 100) > 0.01) {
throw new BadRequestException(`Split percentages must sum to 100, got ${total.toFixed(2)}`);
}
```

A team originally split 40/30/30 that loses its 30%-member's row to a `CASCADE` delete now sums to 70 — `assertValidSplits` correctly rejects it, but that means `splitRelease` throws, which means `markMergedAndRelease` throws (before it ever reaches its own `assertTransition(bounty.status, PAID)` at the end) — the bounty is left stuck in `MERGED` with a `LOCKED` escrow and no application-level way to retry, for exactly the reasons described in the companion "stuck MERGED bounty" issue, except triggered here by a data-integrity gap on an entirely different table than that issue's own root cause. A PR that was correctly merged, for a team that did the work, ends up permanently blocked from paying out because one member's account was deleted at some point after the team was formed — a scenario with no adversarial intent required at all.

## Requirements

- Change `TeamMemberSplit.user`'s relation from `onDelete: 'CASCADE'` to `onDelete: 'RESTRICT'` — a `TeamMemberSplit` is a financial commitment (a promised percentage of a future payout) in exactly the same sense a `Payment` is a record of money that already moved; deleting the `User` it belongs to should refuse, not silently unbalance the team, mirroring `Payment.escrow`'s existing `RESTRICT` reasoning.
- Write the accompanying migration using the same `replaceForeignKeyOnDelete`-style approach already established in `1784272650000-EscrowFkIntegrityAndSponsorId.ts`.
- Since `RESTRICT` alone means "can't delete a user who's on any team" forever (which may be too strong once a team's bounty has already fully paid out and the split no longer matters going forward), consider whether team membership should instead be soft-deletable/deactivatable independent of the `User` row itself, so a genuinely-necessary user deletion doesn't get permanently blocked by stale team memberships on already-completed bounties. This is a design decision worth surfacing explicitly in the PR rather than picking `RESTRICT` and calling it done without considering the account-deletion use case it would then block.
- Add a test: create a team with 3 members summing to 100%, delete one member's `User` row, assert either (a) the delete is rejected (if `RESTRICT` is the chosen fix) or (b) whatever softer mechanism is chosen still results in `team.splits` continuing to sum to 100% for any *not-yet-paid* bounty using that team.

## Acceptance Criteria

- [ ] Deleting a `User` who is a member of a team whose bounty payout hasn't completed no longer silently removes their `TeamMemberSplit` row and desyncs the split sum.
- [ ] A migration implements the FK change.
- [ ] The tension between "must not silently break team payouts" and "must not permanently block legitimate account deletion" is explicitly addressed in the PR, not just papered over with a blanket `RESTRICT`.
- [ ] A test reproduces the pre-fix scenario (team member deleted, subsequent `markMergedAndRelease` throws and leaves the bounty stuck) and proves it no longer happens post-fix.

## Additional Notes

**Precise references:** `src/common/entities/team-member-split.entity.ts:24-29` (the bug), `src/common/entities/bounty.entity.ts:29-46` (the correctly-`SET NULL`'d sibling relations on the same general "user referenced from a financial entity" pattern), `src/common/entities/payment.entity.ts:20-34` (the `RESTRICT` pattern this fix should most closely mirror, given `TeamMemberSplit` is arguably closer in spirit to "a financial commitment" than `Payment.recipient` is), `src/teams/team-split.util.ts:8-23` (`validateSplitPercentages`, the invariant this cascade silently breaks after the fact), `src/bounties/bounties.service.ts:101-119` (`markMergedAndRelease`'s team-split branch, where the broken invariant surfaces), `src/escrow/escrow.service.ts:269-284` (`assertValidSplits`, correctly rejecting the now-broken split — the guard works exactly as designed, it's the upstream data integrity that's the actual bug).

**Test/reproduction plan:**
```ts
const team = await teamsService.create({ name: 't', members: [
{ userId: userA.id, percentage: 40 }, { userId: userB.id, percentage: 30 }, { userId: userC.id, percentage: 30 },
]});
const bounty = await bountiesService.create({ ...dto });
await bountiesService.fund(bounty.id, funderAddress);
await teamsService.assignToBounty(team.id, bounty.id);
await userRepo.delete(userB.id); // pre-fix: cascades, team now has 2 splits summing to 70

await bountiesService.claim(bounty.id, userA.id);
await bountiesService.markInReview(bounty.id, prUrl, prNumber);
await expect(bountiesService.markMergedAndRelease(bounty.id)).rejects.toThrow();
// pre-fix: throws BadRequestException from assertValidSplits, bounty stuck at MERGED with LOCKED escrow
// post-fix: userRepo.delete(userB.id) itself was rejected (or handled) before ever reaching this state
```

**Cross-references:** same underlying pattern — a cascade relation this codebase's FK-hardening migration didn't reach — as the companion "Bounty.issue uses onDelete: CASCADE" issue, on a different table. Also directly compounds with the companion "stuck MERGED bounty" issue: this is a second, independent root cause (alongside plain transient release failures) that can put a bounty into that exact stuck state, so any retry mechanism built to address that issue needs to also be reachable for this failure mode, not just the escrow-call-failure case that issue primarily describes.
26 changes: 26 additions & 0 deletions spec/features/issue-58-team-member-split-fk/tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Task List: Fix TeamMemberSplit FK Integrity

- [X] **Phase 1: Setup & Reproduce**
- [X] Create a new test file `test/team-split-integrity.e2e-spec.ts`.
- [X] Implement the reproduction test case defined in `spec.md` (create team, fund bounty, attempt user deletion, assert rejection).
- [X] Run the test to confirm it fails as expected (i.e., the user is deleted and splits are broken, or the delete succeeds but causes issues later).

- [X] **Phase 2: Entity Change**
- [X] Modify `src/common/entities/team-member-split.entity.ts`: change `onDelete: 'CASCADE'` to `onDelete: 'RESTRICT'` in `user` relation.
- [X] Verify that TypeScript compiles correctly (`npm run build`).

- [X] **Phase 3: Database Migration**
- [X] Generate a new migration: `npm run migration:generate -- src/database/migrations/UpdateTeamMemberSplitOnDelete`
- [X] Edit the generated migration file to ensure it correctly drops and recreates the foreign key constraint with `ON DELETE RESTRICT`.
- [X] Run the migration: `npm run migration:run`.
- [X] Verify database schema (e.g., using `psql` or TypeORM CLI) to confirm the new FK constraint exists.

- [X] **Phase 4: Verify Fix**
- [X] Run the reproduction test created in Phase 1 again.
- [X] Verify the test now passes: the deletion should be blocked by the DB constraint.
- [X] Ensure `npm run test` and `npm run test:e2e` pass.

- [X] **Phase 5: Cleanup & PR Preparation**
- [X] Add explicit commentary/documentation in the PR description regarding the design decision to use `RESTRICT` and the necessity of future soft-delete functionality.
- [X] Final code review: ensure code style matches existing conventions.
- [X] Verify `npm run lint`.
2 changes: 1 addition & 1 deletion src/common/entities/team-member-split.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class TeamMemberSplit {
@Column()
teamId: string;

@ManyToOne(() => User, { onDelete: 'CASCADE' })
@ManyToOne(() => User, { onDelete: 'RESTRICT' })
@JoinColumn()
user: User;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

export class UpdateTeamMemberSplitOnDelete1784600000000 implements MigrationInterface {
name = 'UpdateTeamMemberSplitOnDelete1784600000000';

public async up(queryRunner: QueryRunner): Promise<void> {
await this.replaceForeignKeyOnDelete(
queryRunner,
'team_member_splits',
'userId',
'users',
'RESTRICT',
);
}

public async down(queryRunner: QueryRunner): Promise<void> {
await this.replaceForeignKeyOnDelete(
queryRunner,
'team_member_splits',
'userId',
'users',
'CASCADE',
);
}

private async replaceForeignKeyOnDelete(
queryRunner: QueryRunner,
table: string,
column: string,
refTable: string,
onDelete: 'SET NULL' | 'CASCADE' | 'RESTRICT',
): Promise<void> {
const rows = (await queryRunner.query(
`
SELECT con.conname
FROM pg_constraint con
JOIN pg_class rel ON rel.oid = con.conrelid
JOIN pg_attribute att
ON att.attrelid = con.conrelid AND att.attnum = ANY(con.conkey)
WHERE con.contype = 'f'
AND rel.relname = $1
AND att.attname = $2
`,
[table, column],
)) as Array<{ conname: string }>;

if (rows.length === 0) {
return;
}

const { conname } = rows[0];
await queryRunner.query(
`ALTER TABLE "${table}" DROP CONSTRAINT "${conname}"`,
);
await queryRunner.query(
`ALTER TABLE "${table}" ADD CONSTRAINT "${conname}" FOREIGN KEY ("${column}") REFERENCES "${refTable}"("id") ON DELETE ${onDelete}`,
);
}
}
60 changes: 60 additions & 0 deletions test/team-split-integrity.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TypeOrmModule, getRepositoryToken } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { User } from '../src/common/entities/user.entity';
import { Team } from '../src/common/entities/team.entity';
import { TeamMemberSplit } from '../src/common/entities/team-member-split.entity';
import { entities } from '../src/common/entities/typeorm-entities';

describe('TeamSplitIntegrity (Integration)', () => {
let userRepo: Repository<User>;
let teamRepo: Repository<Team>;
let splitRepo: Repository<TeamMemberSplit>;
let moduleFixture: TestingModule;

beforeAll(async () => {
moduleFixture = await Test.createTestingModule({
imports: [
TypeOrmModule.forRoot({
type: 'postgres',
host: 'localhost',
port: 5432,
username: 'postgres',
password: 'postgres',
database: 'mergefi',
entities: entities,
synchronize: true,
}),
TypeOrmModule.forFeature([User, Team, TeamMemberSplit]),
],
}).compile();

userRepo = moduleFixture.get(getRepositoryToken(User));
teamRepo = moduleFixture.get(getRepositoryToken(Team));
splitRepo = moduleFixture.get(getRepositoryToken(TeamMemberSplit));
});

it('should block deletion of a user that is part of a team split (RESTRICT)', async () => {
// 1. Create User
const user = await userRepo.save(userRepo.create({ username: 'u1' }));

// 2. Create Team and Split
const team = await teamRepo.save(teamRepo.create({ name: 'test-team' }));
await splitRepo.save({
teamId: team.id,
userId: user.id,
percentage: '100.00',
});

// 3. Attempt to delete user - should throw DB error due to RESTRICT FK
await expect(userRepo.delete(user.id)).rejects.toThrow();

// 4. Verify split row still exists
const split = await splitRepo.findOne({ where: { userId: user.id } });
expect(split).toBeDefined();
});

afterAll(async () => {
await moduleFixture.close();
});
});
8 changes: 2 additions & 6 deletions test/users.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,7 @@
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
controllers: [UsersController],
providers: [
{ provide: UsersService, useValue: mockUsersService },
],
providers: [{ provide: UsersService, useValue: mockUsersService }],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => false }) // Simulate unauthenticated
Expand All @@ -34,15 +32,13 @@

describe('GET /users', () => {
it('should reject unauthenticated requests with 401', () => {
return request(app.getHttpServer())
.get('/users')
.expect(403); // Assuming the guard returns 403 when not authorized
return request(app.getHttpServer()).get('/users').expect(403); // Assuming the guard returns 403 when not authorized

Check warning on line 35 in test/users.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type `any` assigned to a parameter of type `App`

Check warning on line 35 in test/users.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type `any` assigned to a parameter of type `App`
});
});

describe('GET /users/:id', () => {
it('should reject unauthenticated requests with 401', () => {
return request(app.getHttpServer())

Check warning on line 41 in test/users.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type `any` assigned to a parameter of type `App`

Check warning on line 41 in test/users.e2e-spec.ts

View workflow job for this annotation

GitHub Actions / ci

Unsafe argument of type `any` assigned to a parameter of type `App`
.get('/users/00000000-0000-0000-0000-000000000000')
.expect(403);
});
Expand Down
Loading