import { Body, Get, Param, Controller, HttpCode, Post, Req, UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; import { VerificationService } from './verification.service'; import { SubmitCheckDto } from './dto/submit-check.dto'; import { AppealDto } from './dto/appeal.dto'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { OptionalJwtAuthGuard } from '../auth/optional-jwt-auth.guard'; import { RateLimit, RateLimitGuard } from '../../common/guards/rate-limit.guard'; import { clientIp } from '../../common/client-ip'; @ApiTags('Verification') @Controller('verification') export class VerificationController { constructor(private readonly verification: VerificationService) {} /** * Submit a location and presence check. * * Deliberately reachable without a JWT: the first place this runs is the * public apply page, before the candidate has an account. When a token IS * present the session is attributed to that user. */ @Post('session') @HttpCode(201) @UseGuards(OptionalJwtAuthGuard, RateLimitGuard) @RateLimit({ limit: 20, windowMs: 60_000 }) @ApiBearerAuth() @ApiOperation({ summary: 'Run a location and presence check', description: 'Works with or without authentication. Returns a verdict of clean, review or blocked, ' + 'plus the findings behind it. Nothing here auto-rejects a candidate: `blocked` means ' + 'something is fixable (turn off the VPN), `review` means a human should look.', }) @ApiResponse({ status: 201, description: 'Check recorded; verdict returned.' }) @ApiResponse({ status: 429, description: 'Rate limited by IP.' }) async submit(@Body() dto: SubmitCheckDto, @Req() req: Request) { const user = req.user as { id?: string } | undefined; return this.verification.submit(dto, { ip: clientIp(req), userAgent: req.headers['user-agent'] ?? null, acceptLanguage: req.headers['accept-language'] ?? null, userId: user?.id ?? null, campaignCandidateId: dto.campaignCandidateId ?? null, campaignId: dto.campaignId ?? null, }); } /** * Dispute a check that got it wrong. * * A real JWT, not the optional one: an appeal has to belong to somebody or it * cannot be answered. This is only reachable because the check now runs after * signup — before that, the people being refused had no account to appeal * from. */ @Post('session/:id/appeal') @HttpCode(200) @UseGuards(JwtAuthGuard, RateLimitGuard) @RateLimit({ limit: 10, windowMs: 60_000 }) @ApiBearerAuth() @ApiOperation({ summary: 'Tell us this check got it wrong' }) async appeal(@Param('id') id: string, @Body() dto: AppealDto, @Req() req: Request) { const user = req.user as { id: string }; return this.verification.appeal(id, user.id, dto); } /** Whether this check can still be disputed, and what was already said. */ @Get('session/:id/appeal') @UseGuards(JwtAuthGuard) @ApiBearerAuth() @ApiOperation({ summary: 'The state of my dispute on one check' }) async myAppeal(@Param('id') id: string, @Req() req: Request) { const user = req.user as { id: string }; return this.verification.myAppeal(id, user.id); } }