Prueba Técnica

Tech Lead · Ingeniería

A continuación se detallan las dos partes que forman esta prueba técnica. Están pensadas para completarse en 2–3 horas. No buscamos perfección exhaustiva: buscamos ver cómo piensas, qué priorizas y cómo comunicas tus decisiones técnicas.

Puedes entregar en el idioma que prefieras (español o inglés).

Instrucciones generales

1. Code Review ~60–90 min

El siguiente código pertenece a una funcionalidad real de nuestro sistema: el registro de un afiliado y la asignación de su primer programa de comisión. Nuestro stack principal es PHP 8 + Symfony.

Tu tarea es revisar este código como si fuera una Pull Request de un desarrollador de tu equipo. Debes:

No es necesario reescribir todo el código. Sí es importante que priorices los problemas por impacto y que expliques tu razonamiento.

Código a revisar

AffiliateController.php
<?php

namespace App\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Doctrine\ORM\EntityManagerInterface;

class AffiliateController extends AbstractController
{
    #[Route('/api/affiliates/register', methods: ['POST'])]
    public function register(Request $request, EntityManagerInterface $em): JsonResponse
    {
        $data = json_decode($request->getContent(), true);

        // Check if email already exists
        $existing = $em->getConnection()->executeQuery(
            "SELECT * FROM affiliates WHERE email = '" . $data['email'] . "'"
        )->fetchAssociative();

        if ($existing) {
            return new JsonResponse(['error' => 'Email already exists'], 400);
        }

        $password = md5($data['password']);

        $em->getConnection()->executeStatement(
            "INSERT INTO affiliates (name, email, password, country, created_at)
             VALUES ('" . $data['name'] . "',
                     '" . $data['email'] . "',
                     '" . $password . "',
                     '" . $data['country'] . "',
                     '" . date('Y-m-d H:i:s') . "')"
        );

        $affiliateId = $em->getConnection()->lastInsertId();

        // Assign default commission program
        $program = $em->getConnection()->executeQuery(
            "SELECT * FROM commission_programs WHERE is_default = 1"
        )->fetchAssociative();

        if ($program) {
            $em->getConnection()->executeStatement(
                "INSERT INTO affiliate_programs (affiliate_id, program_id, assigned_at)
                 VALUES (" . $affiliateId . ", " . $program['id'] . ", NOW())"
            );
        }

        // Send welcome email
        $mailer = new \App\Service\MailerService();
        $mailer->send(
            $data['email'],
            'Welcome to Betandeal!',
            'welcome_affiliate',
            ['name' => $data['name'], 'program' => $program['name'] ?? 'Standard']
        );

        // Log the registration
        file_put_contents(
            '/var/log/affiliates.log',
            date('Y-m-d H:i:s') . ' - New affiliate: ' . $data['email'] . "\n",
            FILE_APPEND
        );

        return new JsonResponse([
            'success'      => true,
            'affiliate_id' => $affiliateId,
            'program'      => $program['name'] ?? null,
        ]);
    }

    #[Route('/api/affiliates/{id}/stats', methods: ['GET'])]
    public function getStats(int $id, Request $request, EntityManagerInterface $em): JsonResponse
    {
        $from = $request->query->get('from', date('Y-m-01'));
        $to   = $request->query->get('to', date('Y-m-d'));

        $stats = $em->getConnection()->executeQuery("
            SELECT
                COUNT(c.id) as clicks,
                COUNT(cv.id) as conversions,
                SUM(cv.commission_amount) as revenue
            FROM clicks c
            LEFT JOIN conversions cv ON cv.click_id = c.id
            WHERE c.affiliate_id = $id
            AND c.created_at BETWEEN '$from' AND '$to'
        ")->fetchAssociative();

        $topOffers = $em->getConnection()->executeQuery("
            SELECT o.name, COUNT(c.id) as clicks
            FROM clicks c
            JOIN offers o ON o.id = c.offer_id
            WHERE c.affiliate_id = $id
            GROUP BY o.id
            ORDER BY clicks DESC
            LIMIT 5
        ")->fetchAllAssociative();

        return new JsonResponse([
            'stats'      => $stats,
            'top_offers' => $topOffers,
        ]);
    }
}
MailerService.php
<?php

namespace App\Service;

class MailerService
{
    private $apiKey    = 'SG.live_xK92mQpLTz3nVwRb8fYoAE';
    private $fromEmail = 'noreply@betandeal.com';

    public function send($to, $subject, $template, $vars = [])
    {
        $html = file_get_contents(__DIR__ . '/../../templates/emails/' . $template . '.html');

        foreach ($vars as $key => $value) {
            $html = str_replace(' . $key . ', $value, $html);
        }

        $ch = curl_init('https://api.sendgrid.com/v3/mail/send');
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Authorization: Bearer ' . $this->apiKey,
            'Content-Type: application/json',
        ]);
        curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
            'personalizations' => [['to' => [['email' => $to]]]],
            'from'             => ['email' => $this->fromEmail],
            'subject'          => $subject,
            'content'          => [['type' => 'text/html', 'value' => $html]],
        ]));

        curl_exec($ch);
        curl_close($ch);
    }
}

📄 Formato de entrega — Parte 1

Entrega tu revisión en un fichero code-review.md con la siguiente estructura:

## Problema 1 — [Título corto]
**Severidad:** Crítica / Alta / Media / Baja
**Descripción:** ...
**Propuesta de mejora:** ...
[fragmento de código si aplica]

## Problema 2 — ...

2. Propuesta de Arquitectura ~60–90 min

Contexto

Betandeal opera una plataforma de afiliación para iGaming. Los afiliados (webs, influencers, comparadores) generan tráfico hacia los operadores de casino y apuestas mediante links de seguimiento. Cada vez que un usuario hace clic en un link y posteriormente realiza una conversión (registro, depósito), el afiliado recibe una comisión.

Actualmente el sistema de tracking funciona así:

El problema

El sistema actual tiene dificultades para escalar. En eventos especiales (Champions League, Grand Prix…) recibimos picos de 50.000–100.000 clics por hora. Además:

  • Algunos operadores envían postbacks duplicados para la misma conversión.
  • El cálculo de comisiones es síncrono y tarda hasta 2 segundos por conversión.
  • Los afiliados exigen dashboards con datos casi en tiempo real (< 5 min de lag).
  • Actualmente todo corre en un único servidor con PHP-FPM + MySQL.

Tu tarea

Diseña una arquitectura que resuelva estos problemas. Tu propuesta debe cubrir:

Restricciones y contexto adicional

📄 Formato de entrega — Parte 2

Un fichero architecture.md con tu propuesta. Incluye diagramas si lo consideras útil (puede ser ASCII art, Mermaid o una imagen). No es necesario que sea un documento exhaustivo: valoramos más la claridad y el criterio que la extensión.

Criterios de evaluación

Área Qué evaluamos
Code Review Capacidad de identificar problemas reales, priorización por impacto, calidad de las propuestas de mejora, comunicación clara.
Arquitectura Pragmatismo, conocimiento de patrones distribuidos, capacidad de equilibrar complejidad y coste, consideración del equipo y la migración incremental.
Comunicación técnica Claridad de la escritura, estructura de las respuestas, capacidad de justificar decisiones.
Criterio Tech Lead ¿Priorizas bien? ¿Piensas en el equipo? ¿Equilibras deuda técnica vs. entrega?