diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 374034e97..b4b06851b 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -1,7 +1,6 @@ name: Backend CI on: - workflow_dispatch: push: branches: [ "main" ] paths: @@ -10,7 +9,6 @@ on: branches: [ "main" ] paths: - 'apps/backend/**' - - 'apps/backend/.ci-trigger' jobs: backend-checks: diff --git a/.github/workflows/contract-drift.yml b/.github/workflows/contract-drift.yml new file mode 100644 index 000000000..3c45641ce --- /dev/null +++ b/.github/workflows/contract-drift.yml @@ -0,0 +1,40 @@ +name: Contract Drift + +on: + pull_request: + paths: + - apps/onchain/testnet-manifest.json + - apps/backend/.env.example + - apps/webapp/.env.local.example + - apps/mobile/.env.example + - apps/backend/src/config/contract-drift.detector.ts + - apps/backend/scripts/check-contract-drift.ts + - apps/backend/CONTRACT_DRIFT_DETECTOR.md + - .github/workflows/contract-drift.yml + schedule: + - cron: "17 9 * * *" + workflow_dispatch: + +jobs: + check: + name: Check contract IDs + runs-on: ubuntu-latest + defaults: + run: + working-directory: apps/backend + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: apps/backend/package-lock.json + + - name: Install backend dependencies + run: npm ci + + - name: Detect contract drift + run: npm run contract:drift diff --git a/.github/workflows/data-processing.yml b/.github/workflows/data-processing.yml index e3502662f..5a0b1c766 100644 --- a/.github/workflows/data-processing.yml +++ b/.github/workflows/data-processing.yml @@ -1,7 +1,6 @@ name: Data Processing CI on: - workflow_dispatch: push: branches: [ "main" ] paths: @@ -10,7 +9,6 @@ on: branches: [ "main" ] paths: - 'apps/data-processing/**' - - 'apps/data-processing/.ci-trigger' jobs: data-processing-checks: diff --git a/.github/workflows/onchain.yml b/.github/workflows/onchain.yml index 89dcb3c6d..f56aad088 100644 --- a/.github/workflows/onchain.yml +++ b/.github/workflows/onchain.yml @@ -1,7 +1,6 @@ name: Onchain CI on: - workflow_dispatch: push: branches: [ "main" ] paths: @@ -10,7 +9,6 @@ on: branches: [ "main" ] paths: - 'apps/onchain/**' - - 'apps/onchain/.ci-trigger' jobs: onchain-checks: diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 0d7c95844..1d6f353a4 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -62,6 +62,7 @@ import { AuditLogInterceptor } from './audit/interceptors/audit-log.interceptor' import { SorobanEventsModule } from './soroban-events/soroban-events.module'; import { TreasuryModule } from './treasury/treasury.module'; import { VestingWalletModule } from './vesting-wallet/vesting-wallet.module'; +import { VerificationRequestsModule } from './verification-requests/verification-requests.module'; import { ContractsModule } from './contracts/contracts.module'; import { ContractAdminModule } from './contract-admin/contract-admin.module'; import { ReviewMetricsModule } from './review-metrics/review-metrics.module'; @@ -123,6 +124,7 @@ import { ContributorFeedModule } from './contributor-feed/contributor-feed.modul ExchangeRatesModule, GrantsModule, VerificationModule, + VerificationRequestsModule, WatchlistModule, OutboxModule, ExportModule, diff --git a/apps/backend/src/database/migrations/1801000000000-CreateVerificationRequests.ts b/apps/backend/src/database/migrations/1801000000000-CreateVerificationRequests.ts new file mode 100644 index 000000000..61dfb85b4 --- /dev/null +++ b/apps/backend/src/database/migrations/1801000000000-CreateVerificationRequests.ts @@ -0,0 +1,34 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class CreateVerificationRequests1801000000000 + implements MigrationInterface +{ + name = 'CreateVerificationRequests1801000000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "verification_requests_targettype_enum" AS ENUM ('CONTRIBUTOR', 'PROJECT')`, + ); + await queryRunner.query( + `CREATE TYPE "verification_requests_status_enum" AS ENUM ('SUBMITTED', 'IN_REVIEW', 'CHANGES_REQUESTED', 'APPROVED', 'REJECTED', 'CANCELLED')`, + ); + await queryRunner.query( + `CREATE TABLE "verification_requests" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "targetType" "verification_requests_targettype_enum" NOT NULL, "targetId" character varying(255) NOT NULL, "requesterId" uuid NOT NULL, "status" "verification_requests_status_enum" NOT NULL, "evidence" text NOT NULL, "requesterNote" text, "reviewerId" uuid, "reviewNote" text, "reviewedAt" TIMESTAMP WITH TIME ZONE, "version" integer NOT NULL DEFAULT 1, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_verification_requests" PRIMARY KEY ("id"), CONSTRAINT "FK_verification_requests_requester" FOREIGN KEY ("requesterId") REFERENCES "users"("id") ON DELETE RESTRICT, CONSTRAINT "FK_verification_requests_reviewer" FOREIGN KEY ("reviewerId") REFERENCES "users"("id") ON DELETE SET NULL)`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_verification_requests_target" ON "verification_requests" ("targetType", "targetId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_verification_requests_requester_status" ON "verification_requests" ("requesterId", "status")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_verification_requests_status_created" ON "verification_requests" ("status", "createdAt")`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE "verification_requests"`); + await queryRunner.query(`DROP TYPE "verification_requests_status_enum"`); + await queryRunner.query(`DROP TYPE "verification_requests_targettype_enum"`); + } +} diff --git a/apps/backend/src/verification-requests/dto/verification-request.dto.ts b/apps/backend/src/verification-requests/dto/verification-request.dto.ts new file mode 100644 index 000000000..8872772c3 --- /dev/null +++ b/apps/backend/src/verification-requests/dto/verification-request.dto.ts @@ -0,0 +1,98 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; +import { + VerificationRequestStatus, + VerificationRequestTargetType, +} from '../entities/verification-request.entity'; + +export class CreateVerificationRequestDto { + @ApiProperty({ enum: VerificationRequestTargetType }) + @IsEnum(VerificationRequestTargetType) + targetType: VerificationRequestTargetType; + + @ApiProperty({ example: 'project-123' }) + @IsString() + @MinLength(1) + @MaxLength(255) + targetId: string; + + @ApiProperty({ description: 'Links or other evidence supporting the request' }) + @IsString() + @MinLength(1) + @MaxLength(10000) + evidence: string; + + @ApiPropertyOptional({ description: 'Optional context for reviewers' }) + @IsOptional() + @IsString() + @MaxLength(4000) + requesterNote?: string; +} + +export class UpdateVerificationRequestStatusDto { + @ApiProperty({ enum: VerificationRequestStatus }) + @IsEnum(VerificationRequestStatus) + status: VerificationRequestStatus; + + @ApiPropertyOptional({ description: 'Reviewer decision or requested changes' }) + @IsOptional() + @IsString() + @MaxLength(4000) + reviewNote?: string; +} + +export class VerificationRequestQueryDto { + @ApiPropertyOptional({ enum: VerificationRequestStatus }) + @IsOptional() + @IsEnum(VerificationRequestStatus) + status?: VerificationRequestStatus; + + @ApiPropertyOptional({ enum: VerificationRequestTargetType }) + @IsOptional() + @IsEnum(VerificationRequestTargetType) + targetType?: VerificationRequestTargetType; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + targetId?: string; +} + +export class VerificationRequestResponseDto { + @ApiProperty() + @IsUUID() + id: string; + + @ApiProperty({ enum: VerificationRequestTargetType }) + targetType: VerificationRequestTargetType; + + @ApiProperty() + targetId: string; + + @ApiProperty() + requesterId: string; + + @ApiProperty({ enum: VerificationRequestStatus }) + status: VerificationRequestStatus; + + @ApiProperty() + evidence: string; + + @ApiPropertyOptional() + requesterNote?: string | null; + + @ApiPropertyOptional() + reviewerId?: string | null; + + @ApiPropertyOptional() + reviewNote?: string | null; + + @ApiPropertyOptional() + reviewedAt?: Date | null; + + @ApiProperty() + createdAt: Date; + + @ApiProperty() + updatedAt: Date; +} diff --git a/apps/backend/src/verification-requests/entities/verification-request.entity.ts b/apps/backend/src/verification-requests/entities/verification-request.entity.ts new file mode 100644 index 000000000..73019e204 --- /dev/null +++ b/apps/backend/src/verification-requests/entities/verification-request.entity.ts @@ -0,0 +1,79 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, + VersionColumn, +} from 'typeorm'; +import { User } from '../../users/entities/user.entity'; + +export enum VerificationRequestTargetType { + CONTRIBUTOR = 'CONTRIBUTOR', + PROJECT = 'PROJECT', +} + +export enum VerificationRequestStatus { + SUBMITTED = 'SUBMITTED', + IN_REVIEW = 'IN_REVIEW', + CHANGES_REQUESTED = 'CHANGES_REQUESTED', + APPROVED = 'APPROVED', + REJECTED = 'REJECTED', + CANCELLED = 'CANCELLED', +} + +@Entity('verification_requests') +@Index(['targetType', 'targetId']) +@Index(['requesterId', 'status']) +@Index(['status', 'createdAt']) +export class VerificationRequest { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'enum', enum: VerificationRequestTargetType }) + targetType: VerificationRequestTargetType; + + @Column({ type: 'varchar', length: 255 }) + targetId: string; + + @Column({ type: 'uuid' }) + requesterId: string; + + @ManyToOne(() => User, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'requesterId' }) + requester: User; + + @Column({ type: 'enum', enum: VerificationRequestStatus }) + status: VerificationRequestStatus; + + @Column({ type: 'text' }) + evidence: string; + + @Column({ type: 'text', nullable: true }) + requesterNote?: string | null; + + @Column({ type: 'uuid', nullable: true }) + reviewerId?: string | null; + + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'reviewerId' }) + reviewer?: User | null; + + @Column({ type: 'text', nullable: true }) + reviewNote?: string | null; + + @Column({ type: 'timestamptz', nullable: true }) + reviewedAt?: Date | null; + + @VersionColumn() + version: number; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/apps/backend/src/verification-requests/verification-requests.controller.ts b/apps/backend/src/verification-requests/verification-requests.controller.ts new file mode 100644 index 000000000..a04db7dee --- /dev/null +++ b/apps/backend/src/verification-requests/verification-requests.controller.ts @@ -0,0 +1,54 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { Request } from 'express'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { RolesGuard } from '../auth/roles.guard'; +import { Roles } from '../auth/decorators/auth.decorators'; +import { UserRole } from '../users/entities/user.entity'; +import { CreateVerificationRequestDto, UpdateVerificationRequestStatusDto, VerificationRequestQueryDto, VerificationRequestResponseDto } from './dto/verification-request.dto'; +import { VerificationRequestsService } from './verification-requests.service'; + +interface AuthenticatedRequest extends Request { user: { id: string; role: UserRole } } + +@ApiTags('verification-requests') +@ApiBearerAuth('JWT-auth') +@Controller('verification-requests') +@UseGuards(JwtAuthGuard) +export class VerificationRequestsController { + constructor(private readonly service: VerificationRequestsService) {} + + @Post() + @ApiOperation({ summary: 'Submit a verification request for a contributor or project' }) + @ApiResponse({ status: 201, type: VerificationRequestResponseDto }) + create(@Req() req: AuthenticatedRequest, @Body() dto: CreateVerificationRequestDto) { + return this.service.create(req.user.id, dto); + } + + @Get('mine') + @ApiOperation({ summary: 'List verification requests submitted by the current user' }) + findMine(@Req() req: AuthenticatedRequest) { return this.service.findMine(req.user.id); } + + @Get() + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.REVIEWER) + @ApiOperation({ summary: 'List verification requests for the reviewer queue' }) + findAll(@Query() query: VerificationRequestQueryDto) { return this.service.findAll(query); } + + @Get(':id') + @ApiOperation({ summary: 'Retrieve a verification request and its current lifecycle state' }) + findOne(@Param('id') id: string, @Req() req: AuthenticatedRequest) { + return this.service.findOne(id, req.user.id, req.user.role); + } + + @Patch(':id/status') + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.REVIEWER) + @ApiOperation({ summary: 'Safely transition a verification request as a reviewer or admin' }) + transition(@Param('id') id: string, @Req() req: AuthenticatedRequest, @Body() dto: UpdateVerificationRequestStatusDto) { + return this.service.transition(id, req.user.id, dto); + } + + @Post(':id/cancel') + @ApiOperation({ summary: 'Cancel the current user\'s submitted verification request' }) + cancel(@Param('id') id: string, @Req() req: AuthenticatedRequest) { return this.service.cancel(id, req.user.id); } +} diff --git a/apps/backend/src/verification-requests/verification-requests.module.ts b/apps/backend/src/verification-requests/verification-requests.module.ts new file mode 100644 index 000000000..9be86d8a5 --- /dev/null +++ b/apps/backend/src/verification-requests/verification-requests.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { VerificationRequest } from './entities/verification-request.entity'; +import { VerificationRequestsController } from './verification-requests.controller'; +import { VerificationRequestsService } from './verification-requests.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([VerificationRequest])], + controllers: [VerificationRequestsController], + providers: [VerificationRequestsService], +}) +export class VerificationRequestsModule {} diff --git a/apps/backend/src/verification-requests/verification-requests.service.spec.ts b/apps/backend/src/verification-requests/verification-requests.service.spec.ts new file mode 100644 index 000000000..3d5b1e83a --- /dev/null +++ b/apps/backend/src/verification-requests/verification-requests.service.spec.ts @@ -0,0 +1,91 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { VerificationRequestsService } from './verification-requests.service'; +import { + VerificationRequest, + VerificationRequestStatus, + VerificationRequestTargetType, +} from './entities/verification-request.entity'; +import { UserRole } from '../users/entities/user.entity'; +import { Repository } from 'typeorm'; + +type VerificationRequestRepositoryMock = jest.Mocked<{ + create: (value: Partial) => VerificationRequest; + save: (value: VerificationRequest) => Promise; + find: () => Promise; + findOne: () => Promise; +}>; + +describe('VerificationRequestsService', () => { + const request: VerificationRequest = { + id: 'request-1', + requesterId: 'user-1', + targetType: VerificationRequestTargetType.PROJECT, + targetId: 'project-1', + status: VerificationRequestStatus.SUBMITTED, + evidence: 'https://example.org/evidence', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + }; + const repository: VerificationRequestRepositoryMock = { + create: jest.fn((value: Partial) => value as VerificationRequest), + save: jest.fn((value: VerificationRequest) => Promise.resolve(value)), + find: jest.fn(), + findOne: jest.fn(), + }; + const service = new VerificationRequestsService(repository as unknown as Repository); + + beforeEach(() => jest.clearAllMocks()); + + it('creates a submitted request for either supported target type', async () => { + repository.find.mockResolvedValue([]); + const result = await service.create('user-1', { + targetType: VerificationRequestTargetType.CONTRIBUTOR, + targetId: 'contributor-1', + evidence: 'https://example.org/profile', + }); + + expect(result.status).toBe(VerificationRequestStatus.SUBMITTED); + expect(result.requesterId).toBe('user-1'); + }); + + it('prevents duplicate open requests for the same requester and target', async () => { + repository.find.mockResolvedValue([request]); + + await expect(service.create('user-1', { + targetType: VerificationRequestTargetType.PROJECT, + targetId: 'project-1', + evidence: 'new evidence', + })).rejects.toThrow(BadRequestException); + }); + + it('allows only explicit reviewer lifecycle transitions', async () => { + repository.findOne.mockResolvedValue({ ...request, status: VerificationRequestStatus.IN_REVIEW }); + const approved = await service.transition('request-1', 'reviewer-1', { + status: VerificationRequestStatus.APPROVED, + reviewNote: 'Evidence verified', + }); + + expect(approved.status).toBe(VerificationRequestStatus.APPROVED); + expect(approved.reviewerId).toBe('reviewer-1'); + + repository.findOne.mockResolvedValue({ ...request, status: VerificationRequestStatus.APPROVED }); + await expect(service.transition('request-1', 'reviewer-1', { + status: VerificationRequestStatus.IN_REVIEW, + })).rejects.toThrow(BadRequestException); + }); + + it('limits individual request visibility to its requester or reviewer roles', async () => { + repository.findOne.mockResolvedValue(request); + await expect(service.findOne('request-1', 'user-2', UserRole.USER)).rejects.toThrow(NotFoundException); + await expect(service.findOne('request-1', 'reviewer-1', UserRole.REVIEWER)).resolves.toEqual(request); + }); + + it('allows requesters to cancel only cancellable states', async () => { + repository.findOne.mockResolvedValue({ ...request, status: VerificationRequestStatus.CHANGES_REQUESTED }); + await expect(service.cancel('request-1', 'user-1')).resolves.toMatchObject({ status: VerificationRequestStatus.CANCELLED }); + + repository.findOne.mockResolvedValue({ ...request, status: VerificationRequestStatus.APPROVED }); + await expect(service.cancel('request-1', 'user-1')).rejects.toThrow(BadRequestException); + }); +}); diff --git a/apps/backend/src/verification-requests/verification-requests.service.ts b/apps/backend/src/verification-requests/verification-requests.service.ts new file mode 100644 index 000000000..dc9dd4d03 --- /dev/null +++ b/apps/backend/src/verification-requests/verification-requests.service.ts @@ -0,0 +1,113 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { UserRole } from '../users/entities/user.entity'; +import { + CreateVerificationRequestDto, + UpdateVerificationRequestStatusDto, + VerificationRequestQueryDto, +} from './dto/verification-request.dto'; +import { + VerificationRequest, + VerificationRequestStatus, +} from './entities/verification-request.entity'; + +const REVIEWER_TRANSITIONS: Record = { + [VerificationRequestStatus.SUBMITTED]: [VerificationRequestStatus.IN_REVIEW], + [VerificationRequestStatus.IN_REVIEW]: [ + VerificationRequestStatus.CHANGES_REQUESTED, + VerificationRequestStatus.APPROVED, + VerificationRequestStatus.REJECTED, + ], + [VerificationRequestStatus.CHANGES_REQUESTED]: [VerificationRequestStatus.IN_REVIEW], + [VerificationRequestStatus.APPROVED]: [], + [VerificationRequestStatus.REJECTED]: [], + [VerificationRequestStatus.CANCELLED]: [], +}; + +@Injectable() +export class VerificationRequestsService { + constructor( + @InjectRepository(VerificationRequest) + private readonly requests: Repository, + ) {} + + async create(requesterId: string, dto: CreateVerificationRequestDto) { + const existing = await this.requests.find({ + where: { requesterId, targetType: dto.targetType, targetId: dto.targetId }, + }); + if (existing.some((request) => this.isOpen(request.status))) { + throw new BadRequestException('An open verification request already exists for this target'); + } + return this.requests.save(this.requests.create({ ...dto, requesterId, status: VerificationRequestStatus.SUBMITTED })); + } + + async findMine(requesterId: string) { + return this.requests.find({ where: { requesterId }, order: { createdAt: 'DESC' } }); + } + + async findAll(query: VerificationRequestQueryDto) { + return this.requests.find({ + where: { + ...(query.status ? { status: query.status } : {}), + ...(query.targetType ? { targetType: query.targetType } : {}), + ...(query.targetId ? { targetId: query.targetId } : {}), + }, + order: { createdAt: 'ASC' }, + }); + } + + async findOne(id: string, requesterId: string, role: UserRole) { + const request = await this.getRequest(id); + if (request.requesterId !== requesterId && !this.canReview(role)) { + throw new NotFoundException('Verification request not found'); + } + return request; + } + + async transition( + id: string, + reviewerId: string, + dto: UpdateVerificationRequestStatusDto, + ) { + const request = await this.getRequest(id); + if (!REVIEWER_TRANSITIONS[request.status].includes(dto.status)) { + throw new BadRequestException(`Cannot transition verification request from ${request.status} to ${dto.status}`); + } + request.status = dto.status; + request.reviewerId = reviewerId; + request.reviewNote = dto.reviewNote ?? null; + request.reviewedAt = new Date(); + return this.requests.save(request); + } + + async cancel(id: string, requesterId: string) { + const request = await this.getRequest(id); + if (request.requesterId !== requesterId) { + throw new NotFoundException('Verification request not found'); + } + if (![VerificationRequestStatus.SUBMITTED, VerificationRequestStatus.CHANGES_REQUESTED].includes(request.status)) { + throw new BadRequestException('Only submitted or changes-requested requests can be cancelled'); + } + request.status = VerificationRequestStatus.CANCELLED; + return this.requests.save(request); + } + + private async getRequest(id: string) { + const request = await this.requests.findOne({ where: { id } }); + if (!request) throw new NotFoundException('Verification request not found'); + return request; + } + + private canReview(role: UserRole) { + return role === UserRole.ADMIN || role === UserRole.REVIEWER; + } + + private isOpen(status: VerificationRequestStatus) { + return [ + VerificationRequestStatus.SUBMITTED, + VerificationRequestStatus.IN_REVIEW, + VerificationRequestStatus.CHANGES_REQUESTED, + ].includes(status); + } +}