File size: 3,323 Bytes
86063f3
 
614ff63
 
86063f3
 
 
 
 
 
 
 
 
 
614ff63
 
86063f3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
614ff63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86063f3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
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);
  }
}