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
2 changes: 0 additions & 2 deletions .github/workflows/backend.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
name: Backend CI

on:
workflow_dispatch:
push:
branches: [ "main" ]
paths:
Expand All @@ -10,7 +9,6 @@ on:
branches: [ "main" ]
paths:
- 'apps/backend/**'
- 'apps/backend/.ci-trigger'

jobs:
backend-checks:
Expand Down
40 changes: 40 additions & 0 deletions .github/workflows/contract-drift.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 0 additions & 2 deletions .github/workflows/data-processing.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
name: Data Processing CI

on:
workflow_dispatch:
push:
branches: [ "main" ]
paths:
Expand All @@ -10,7 +9,6 @@ on:
branches: [ "main" ]
paths:
- 'apps/data-processing/**'
- 'apps/data-processing/.ci-trigger'

jobs:
data-processing-checks:
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/onchain.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
name: Onchain CI

on:
workflow_dispatch:
push:
branches: [ "main" ]
paths:
Expand All @@ -10,7 +9,6 @@ on:
branches: [ "main" ]
paths:
- 'apps/onchain/**'
- 'apps/onchain/.ci-trigger'

jobs:
onchain-checks:
Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -123,6 +124,7 @@ import { ContributorFeedModule } from './contributor-feed/contributor-feed.modul
ExchangeRatesModule,
GrantsModule,
VerificationModule,
VerificationRequestsModule,
WatchlistModule,
OutboxModule,
ExportModule,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { MigrationInterface, QueryRunner } from 'typeorm';

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

public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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); }
}
Original file line number Diff line number Diff line change
@@ -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 {}
Loading
Loading