Creando un Barman IA Sin Servidor - Parte 2: Registro de invitados y actualizaciones en vivo de pedidos

2026-02-04
This post cover image
#aws
#cloud
#serverless
#appsync

Este archivo ha sido traducido automáticamente por IA, pueden ocurrir errores

En Parte 1, construí la base para el asistente de cócteles IA. Los invitados pueden navegar por el menú de bebidas, yo puedo administrar las bebidas y cargar imágenes, y el sistema genera indicaciones de imágenes IA a través de EventBridge. Pero hay un problema: los invitados aún no pueden pedir nada.

Eso es lo que estamos construyendo en esta publicación. Necesito tres cosas para completar el flujo de pedidos. Primero, los invitados deben poder registrarse sin crear cuentas completas; recuerden, este proyecto fue construido para una pequeña fiesta en casa, y pedirle a la gente que verifique correos electrónicos y establezca contraseñas es demasiado. En segundo lugar, todavía necesitamos un flujo de colocación de pedidos autenticado para que las personas puedan enviar y rastrear sus pedidos de bebidas. En tercer lugar, quiero actualizaciones en tiempo real para que cuando marque una bebida como lista, los invitados la vean instantáneamente en sus teléfonos en lugar de actualizar constantemente.

¡Así que comencemos a construir!

¿Por qué no permitir que cualquiera haga un pedido?

La solución más fácil sería dejar que el extremo de pedidos fuera completamente público, con una clave de API. Cualquiera puede enviar pedidos sin autenticación. Pero eso crea algunos problemas que no quiero tener.

Sin identificación de usuarios, no puedo rastrear quién pidió qué. Si tres personas piden un Negroni, ¿cómo sé cuál es cuál cuando estén listos?

Desde una perspectiva de seguridad, un extremo completamente abierto está pidiendo problemas. Cualquiera en internet podría enviar spam de pedidos. Incluso en una fiesta en casa, quiero algún control básico.

La solución debe equilibrar la seguridad con la conveniencia. Los invitados no deberían saltar obstáculos, pero necesito suficiente control para hacer que el sistema sea utilizable.

Registro de invitados con códigos de invitación

Para los administradores, es decir, yo, uso Amazon Cognito con autenticación completa: correo electrónico, contraseña, tokens JWT, todo el paquete. Eso tiene sentido para mí administrar el menú de bebidas y ver todos los pedidos. Pero es excesivo para los invitados de la fiesta.

Quería que fuera lo más simple posible, como una invitación a una fiesta. La idea era que los invitados ingresaran un código de registro, ingresaran su nombre y estuvieran listos para comenzar.

Entonces, ¿cómo creé esa solución? Antes de la fiesta, genero un código de registro. Este código tiene una fecha de vencimiento, válida para el día de la fiesta, y un número establecido de veces que puede usarse, lo que hace posible crear un código válido para el número esperado de invitados más algunos extras. Puedo imprimir el código como un código QR escaneable, lo que facilita aún más el registro. Cuando lleguen los invitados, escanean o ingresan el código junto con el nombre elegido. El sistema valida el código, verifica que no haya alcanzado su uso máximo o que no haya vencido, crea una cuenta de usuario, incrementa el conteo de uso del código y devuelve tokens JWT. El invitado ahora puede hacer pedidos. Pero espera, ¿tokens JWT? ¿No dije que no a los tokens JWT y al registro? Tranquilo, voy a llegar a esa parte.

Así es como se construyó el registro.

Imagen de la arquitectura de registro

El esquema de la base de datos

El sistema de registro agrega dos tablas nuevas al esquema: app_users para los invitados y registration_codes para los códigos de invitación.

CREATE TABLE cocktails.app_users (
    user_key UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    username VARCHAR(100) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_login TIMESTAMP,
    is_active BOOLEAN DEFAULT true,
    metadata TEXT DEFAULT '{}'
);

CREATE TABLE cocktails.registration_codes (
    code UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    created_by VARCHAR(255) NOT NULL,
    expires_at TIMESTAMP NOT NULL,
    max_uses INTEGER NOT NULL,
    use_count INTEGER DEFAULT 0,
    notes TEXT
);

Nombré la tabla app_users en lugar de simplemente users ya que cocktails.users ya estaba en uso para la autenticación de administradores. Mantenerlas separadas hace las cosas mucho más claras.

El user_key es un UUID que identifica al invitado, por lo que podemos tener dos "Sam" en la fiesta.

Los códigos de registro también usan UUIDs como valor de code; esto los hace "imposibles de adivinar"—bueno, podría ser una exageración, pero difíciles de adivinar de todos modos. Cada código tiene un límite de max_uses establecido en el momento de la creación, lo que permite que el mismo código registre a múltiples invitados. El use_count realiza un seguimiento de cuántas veces se ha utilizado el código.

Creación de códigos de registro

Yo, como administrador, creo códigos de registro antes de la fiesta a través de un extremo de administrador. La función Lambda genera un UUID y establece la hora de vencimiento proporcionada.

def handler(event, context):
    """Extremo de administrador - Crear código de registro."""
    body = json.loads(event["body"])
    expires_at = datetime.fromisoformat(body["expires_at"])
    max_uses = body.get("max_uses", 1)
    
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                """INSERT INTO cocktails.registration_codes
                   (created_by, expires_at, max_uses, notes)
                   VALUES (%s, %s, %s, %s)
                   RETURNING code, expires_at, max_uses""",
                [event["requestContext"]["authorizer"]["userId"], 
                 expires_at, max_uses, body.get("notes", "")]
            )
            result = cur.fetchone()
        conn.commit()
    
    return response(201, {
        "code": str(result["code"]),
        "expires_at": result["expires_at"].isoformat(),
        "max_uses": result["max_uses"]
    })

El código generado se ve así: f47ac10b-58cc-4372-a567-0e02b2c3d479. Ahora puedo compartirlo con los invitados como quiera: mensaje de texto, correo electrónico o impreso como un código QR. Para una fiesta en casa con 20 invitados, podría crear un código con max_uses: 25 para tener en cuenta a los acompañantes. Para un evento más grande, podría crear múltiples códigos por mesa o sección.

El flujo de registro

Cuando un invitado se registra, el sistema verifica el código proporcionado y crea la cuenta. La parte interesante aquí es que esta validación ocurre en dos etapas diferentes. Comienza con un autorizador Lambda que se ejecuta antes de que la solicitud llegue a la lógica principal, y luego el extremo en sí verifica todo nuevamente. El trabajo principal del autorizador es asegurarse de que el código sea real y que aún no haya alcanzado su límite de uso.

def handler(event, context):
    """Autorizador Lambda para códigos de registro."""
    code = event["headers"].get("X-Registration-Code", "")
    if not code:
        return generate_policy("unknown", "Deny", event["methodArn"])
    
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                """SELECT code FROM cocktails.registration_codes
                   WHERE code = %s AND use_count < max_uses AND expires_at > NOW()""",
                [code]
            )
            if not cur.fetchone():
                return generate_policy("unknown", "Deny", event["methodArn"])
    
    return generate_policy(code, "Allow", event["methodArn"],
                          context={"registrationCode": code})

Debido a que el autorizador funciona a nivel de API Gateway, un código malo ni siquiera llegará al Lambda de registro. Esta configuración es eficiente porque evita que el Lambda de registro se ejecute para registros falsos, evitando inundaciones. Después de esa verificación inicial, el extremo de registro termina el trabajo creando el usuario en la tabla y aumentando el conteo de uso del código.

def handler(event, context):
    """POST /register - Crear nuevo usuario invitado."""
    code = event["requestContext"]["authorizer"]["registrationCode"]
    username = json.loads(event["body"]).get("username", "").strip()
    
    # Validar nombre de usuario
    if not username or len(username) < 2 or len(username) > 50:
        return response(400, {"error": "El nombre de usuario debe tener entre 2 y 50 caracteres"})
    
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                """INSERT INTO cocktails.app_users (username)
                   VALUES (%s) RETURNING user_key, username""",
                [username]
            )
            user = cur.fetchone()
            
            # Incrementar uso del código
            cur.execute(
                "UPDATE cocktails.registration_codes SET use_count = use_count + 1 WHERE code = %s",
                [code]
            )
        conn.commit()
    
    return response(201, {
        "user": {"user_key": str(user["user_key"]), "username": user["username"]},
        "access_token": generate_access_token(user),
        "refresh_token": generate_refresh_token(user)
    })

El extremo de registro es idempotente de una manera importante. Si el autorizador pasa, sabemos que el código es válido y no ha alcanzado su límite. Si el Lambda falla a mitad del camino debido a un tiempo de espera o un error, el conteo de uso no se incrementa, por lo que el invitado puede reintentar. Una vez que el Lambda confirma la transacción, el conteo de uso aumenta. Cuando use_count alcanza max_uses, el autorizador rechazará futuros intentos de registro con ese código.

Tokens JWT autofirmados

Ahora, volvamos a los tokens JWT—te dije que volveríamos a esto.

Después de un registro exitoso, los invitados reciben tokens JWT. Pero a diferencia del flujo de administrador, que usa tokens emitidos por Cognito, los tokens de invitados están autofirmados. Los genero yo mismo usando pares de claves RSA almacenados en AWS Secrets Manager.

¿Por qué autofirmados en lugar de Cognito?

Cognito es un servicio excelente, pero está diseñado para usuarios de larga duración. Si lo usara aquí, necesitaría crear cuentas en el grupo de usuarios, administrar contraseñas y lidiar con verificaciones de correo electrónico. Eso es mucha sobrecarga para un invitado que solo estará allí por unas horas.

Optar por JWT autofirmados me da la flexibilidad de controlar exactamente qué hay en el token y cuánto tiempo permanece activo. Los invitados no necesitan cosas como autenticación multifactor o restablecimiento de contraseñas. Solo necesitan una forma de demostrar que se registraron para poder pedir durante la noche.

El problema es que tengo que manejar la validación yo mismo. Necesito administrar un par de claves RSA y escribir la lógica de verificación en un autorizador Lambda. Sin embargo, el proceso no es tan complicado, y me gusta tener ese nivel de control sobre todo el flujo.

Creación de tokens de acceso y actualización

El Lambda de registro genera dos tokens: un token de acceso y un token de actualización.

def generate_access_token(user: dict) -> str:
    """Generar token de acceso JWT firmado RS256."""
    payload = {
        "token_type": "access",
        "user_key": str(user["user_key"]),
        "username": user["username"],
        "exp": int(time.time()) + (4 * 60 * 60)  # 4 horas
    }
    return jwt.encode(payload, get_private_key_from_secrets_manager(), algorithm="RS256")

def generate_refresh_token(user: dict) -> str:
    """Generar token de actualización criptográficamente aleatorio."""
    token = secrets.token_urlsafe(32)
    token_hash = hashlib.sha256(token.encode()).hexdigest()
    
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                """INSERT INTO cocktails.refresh_tokens (user_key, token_hash, expires_at)
                   VALUES (%s, %s, %s)""",
                [user["user_key"], token_hash, datetime.utcnow() + timedelta(days=2)]
            )
        conn.commit()
    return token

El token de acceso es un JWT con una caducidad de 4 horas. Cuatro horas son suficientes para una fiesta pero lo suficientemente cortas como para que los tokens robados no duren para siempre. La carga incluye user_key y username para que las funciones Lambda downstream sepan quién está haciendo las solicitudes.

El token de actualización es diferente. No es un JWT, solo una cadena criptográficamente aleatoria. Almaceno el hash SHA-256 en la base de datos junto con su caducidad, 2 días. Esto permite que los invitados mantengan su sesión activa a través de múltiples reaperturas de la aplicación sin necesidad de registrarse nuevamente.

La tabla de tokens de actualización

Los tokens de actualización se almacenan por separado de los usuarios.

CREATE TABLE cocktails.refresh_tokens (
    token_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_key UUID NOT NULL,
    token_hash VARCHAR(64) NOT NULL,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    expires_at TIMESTAMP NOT NULL,
    last_used_at TIMESTAMP,
    is_revoked BOOLEAN DEFAULT false,
    revoked_at TIMESTAMP,
    device_info TEXT
);

¿Por qué hashear el token de actualización en lugar de almacenarlo directamente? Porque los tokens de actualización son de larga duración—si podemos llamar largos a 2 días. Si alguien obtiene acceso a la base de datos, podría usar los tokens almacenados para suplantar a los usuarios. Al hashear, nos aseguramos de que, incluso con acceso a la base de datos, el atacante no pueda reconstruir los tokens originales.

El campo is_revoked me permite invalidar tokens si es necesario. El campo device_info almacena metadatos como el agente de usuario del navegador, útil para depuración o mostrar a los usuarios dónde están conectados.

Extremo de actualización de tokens

Cuando el token de acceso expira, el frontend llama al extremo de actualización con el token de actualización.

def handler(event, context):
    """POST /auth/refresh - Intercambiar token de actualización por un nuevo token de acceso."""
    refresh_token = json.loads(event["body"]).get("refresh_token", "")
    if not refresh_token:
        return response(400, {"error": "Se requiere refresh_token"})
    
    token_hash = hashlib.sha256(refresh_token.encode()).hexdigest()
    
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                """SELECT rt.user_key, u.username FROM cocktails.refresh_tokens rt
                   JOIN cocktails.app_users u ON rt.user_key = u.user_key
                   WHERE rt.token_hash = %s AND NOT rt.is_revoked AND rt.expires_at > NOW()""",
                [token_hash]
            )
            token_record = cur.fetchone()
            if not token_record:
                return response(401, {"error": "Token de actualización inválido o expirado"})
            
            cur.execute(
                "UPDATE cocktails.refresh_tokens SET last_used_at = NOW() WHERE token_hash = %s",
                [token_hash]
            )
        conn.commit()
    
    return response(200, {
        "access_token": generate_access_token({
            "user_key": token_record["user_key"],
            "username": token_record["username"]
        })
    })

Este patrón es una actualización de token estilo OAuth2 estándar. El token de actualización nunca expira siempre que se use periódicamente. El token de acceso expira con frecuencia pero puede renovarse sin reautenticarse.

El autorizador de usuario

Los extremos de pedidos requieren autenticación. El autorizador de usuario Lambda valida los tokens de acceso JWT autofirmados.

def handler(event, context):
    """Autorizador Lambda JWT de usuario - valida tokens autofirmados."""
    try:
        token = extract_bearer_token(event)
        claims = jwt.decode(
            token,
            get_public_key_from_secrets_manager(),
            algorithms=["RS256"],
            options={"verify_signature": True, "verify_exp": True}
        )
        
        if claims.get("token_type") != "access":
            return generate_policy("unknown", "Deny", event["methodArn"])
        
        return generate_policy(
            claims["user_key"], "Allow", event["methodArn"],
            context={"userId": claims["user_key"], "username": claims["username"]}
        )
    except (jwt.ExpiredSignatureError, jwt.InvalidTokenError):
        return generate_policy("unknown", "Deny", event["methodArn"])

Como dije, puedes ver que esto no es tan complejo. El autorizador Lambda, invocado por API Gateway, carga la clave pública de Secrets Manager, verifica la firma del token con la biblioteca PyJWT y comprueba la caducidad. Si todo pasa, extrae user_key y username y los pasa a la función Lambda downstream a través del contexto del autorizador.

Las funciones Lambda downstream no necesitan analizar los tokens JWT ellas mismas. El contexto está disponible en event["requestContext"]["authorizer"].

Caché de la clave pública

Una optimización que vale la pena mencionar es el caché de la clave pública. Cargarla de Secrets Manager en cada solicitud sería lento y costoso. En su lugar, la guardo en una variable global.

_jwt_public_key = None

def get_public_key_from_secrets_manager() -> str:
    """Obtener la clave pública JWT de Secrets Manager con caché."""
    global _jwt_public_key
    
    if _jwt_public_key is not None:
        return _jwt_public_key
    
    client = boto3.client("secretsmanager")
    response = client.get_secret_value(SecretId="drink-assistant/jwt-keys")
    secret = json.loads(response["SecretString"])
    _jwt_public_key = secret["public_key"]
    
    return _jwt_public_key

Los entornos de ejecución de Lambda se reutilizan entre invocaciones. La primera invocación carga la clave y la guarda en caché. Las invocaciones posteriores en el mismo entorno de ejecución reutilizan la clave en caché. Esto reduce las llamadas a la API de Secrets Manager y mejora la latencia en muchos casos—no es perfecto, pero definitivamente es suficiente.

Realizar pedidos

Con el registro y la autenticación completos, los invitados ahora pueden hacer pedidos. El extremo de pedidos está protegido por el autorizador de usuario.

La tabla de pedidos

Los pedidos se almacenan en la tabla cocktails.orders.

CREATE TABLE cocktails.orders (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    drink_id UUID NOT NULL,
    user_key UUID,
    status VARCHAR(50) NOT NULL DEFAULT 'pending',
    created_at TIMESTAMP DEFAULT NOW(),
    updated_at TIMESTAMP DEFAULT NOW(),
    completed_at TIMESTAMP
);

El user_key se vincula a app_users.user_key. El campo status realiza un seguimiento del ciclo de vida del pedido: pending, preparing, ready o served. El tiempo de entrega completed_at se establece cuando el estado llega a served.

Creación de un pedido

El Lambda de creación de pedidos es sencillo. Verifica que la bebida esté disponible—no quiero que los invitados pidan un Negroni si me quedé sin Campari. Luego, crea el registro del pedido y devuelve los detalles del pedido.

def handler(event, context):
    """POST /orders - Crear un nuevo pedido para el usuario autenticado."""
    user_key = event["requestContext"]["authorizer"]["userId"]
    drink_id = json.loads(event["body"]).get("drink_id")
    
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                "SELECT id, name FROM cocktails.drinks WHERE id = %s AND is_active = true",
                [drink_id]
            )
            drink = cur.fetchone()
            if not drink:
                return response(404, {"error": "Bebida no encontrada"})
            
            cur.execute(
                """INSERT INTO cocktails.orders (drink_id, user_key, status)
                   VALUES (%s, %s, 'pending') RETURNING id, status, created_at""",
                [drink_id, user_key]
            )
            order = cur.fetchone()
        conn.commit()
    
    publish_order_created(user_key, event["requestContext"]["authorizer"]["username"], 
                         order, drink["name"])
    
    return response(201, {
        "order": {
            "id": str(order["id"]),
            "drink_name": drink["name"],
            "status": order["status"],
            "created_at": order["created_at"].isoformat()
        }
    })

Listar pedidos del usuario

Los invitados también pueden obtener su historial completo de pedidos.

def handler(event, context):
    """GET /orders - Listar pedidos para el usuario autenticado."""
    with get_connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                """SELECT o.id, d.name AS drink_name, o.status, o.created_at
                   FROM cocktails.orders o JOIN cocktails.drinks d ON o.drink_id = d.id
                   WHERE o.user_key = %s ORDER BY o.created_at DESC""",
                [event["requestContext"]["authorizer"]["userId"]]
            )
            return response(200, {"orders": cur.fetchall()})

La consulta une orders con drinks para incluir el nombre de la bebida en la respuesta. Los invitados ven "Negroni - pendiente" en lugar de un UUID.

Actualizaciones de estado del pedido

Esta es la parte que me emociona mucho y uno de los elementos clave en esta parte. Mientras construía la aplicación, me di cuenta de que había un problema potencial que crearía una mala experiencia de usuario. Si haces un pedido y no tienes forma de saber el progreso, terminas acercándote y molestándome preguntando por tu bebida. Ese tipo de verificación manual realmente anula el propósito de la aplicación.

Sabía que necesitaba algún tipo de sistema en vivo desde el principio. El momento en que marque una bebida como lista en mi extremo, la aplicación en el teléfono del invitado debería actualizarse y notificar. Sin sondeo y sin necesidad de presionar el botón de actualización.

Las actualizaciones en tiempo real suelen ser solo pub/sub

Cuando la gente habla de "actualizaciones en tiempo real", lo que suelen querer desde una perspectiva técnica es un modelo Pub/Sub (Publicar/Subscribirse). Esta es exactamente la razón por la que elegí AWS AppSync Events—es un servicio potente de pub/sub que me permitirá enviar actualizaciones a los usuarios.

En una aplicación web estándar, el cliente debe pedir datos (solicitud/respuesta). En una configuración pub/sub, el cliente se suscribe a un tema específico, como orders/{user_id}. Luego, cada vez que ocurre un cambio en el servidor, el backend publica un mensaje a ese tema. El intermediario pub/sub (en nuestro caso, AppSync Events) se encarga del trabajo pesado de averiguar quién está escuchando y enviar ese mensaje instantáneamente a ellos.

AppSync Events vs EventBridge

En la Parte 1, usé Amazon EventBridge para flujos de eventos como generar indicaciones de imágenes IA. EventBridge es un servicio excelente cuando construyes aplicaciones impulsadas por eventos. Pero EventBridge no envía eventos a aplicaciones de cliente.

Para implementar una configuración pub/sub de cliente, necesitaré usar Websockets de alguna forma. La forma más fácil que he encontrado para obtener una configuración pub/sub sobre websockets es usar AppSync Events—ahora, no dejes que la parte de AppSync en el nombre te engañe. Esto es muy diferente de la API AppSync, que es una implementación administrada de API GraphQL. Es un poco desafortunado, creo, que la implementación pub/sub también haya aterrizado bajo AppSync.

AppSync Events maneja las conexiones, suscripciones y transmisión automáticamente. Solo publico eventos desde Lambda, y AppSync los enruta a los clientes suscritos.

Conceptos de AppSync Events

AppSync Events se basa en tres conceptos.

Canales son como temas en un sistema pub/sub. Los clientes se suscriben a canales para recibir eventos. En mi caso, cada usuario se suscribe a orders/{user_key} para obtener actualizaciones sobre sus pedidos.

Espacios de nombres de canales agrupan canales relacionados. Creo un espacio de nombres llamado orders, y los canales se crean dinámicamente dentro de él. Cuando un usuario se suscribe a orders/abc-123, AppSync crea automáticamente ese canal si no existe.

Eventos son mensajes publicados a canales. Cuando actualizo el estado de un pedido, publico un evento en el canal de ese usuario. AppSync lo transmite a todos los clientes conectados suscritos a ese canal.

Visión general del diseño pub/sub

La configuración pub/sub con AppSync Events se ve así.

Imagen de la arquitectura de registro

Configuración de AppSync Events

CloudFormation crea una API AppSync Events con espacios de nombres de canales.

Resources:
  EventsApi:
    Type: AWS::AppSync::Api
    Properties:
      Name: !Sub "${AWS::StackName}-events-api"
      EventConfig:
        AuthProviders:
          - AuthType: API_KEY
        ConnectionAuthModes:
          - AuthType: API_KEY
        DefaultPublishAuthModes:
          - AuthType: API_KEY
        DefaultSubscribeAuthModes:
          - AuthType: API_KEY

  EventsApiKey:
    Type: AWS::AppSync::ApiKey
    Properties:
      ApiId: !GetAtt EventsApi.ApiId
      Description: API Key for AppSync Events
      Expires: 1767225600  # Caducidad lejana

  OrdersChannelNamespace:
    Type: AWS::AppSync::ChannelNamespace
    Properties:
      ApiId: !GetAtt EventsApi.ApiId
      Name: orders
      SubscribeAuthModes:
        - AuthType: API_KEY
      PublishAuthModes:
        - AuthType: API_KEY

  AdminChannelNamespace:
    Type: AWS::AppSync::ChannelNamespace
    Properties:
      ApiId: !GetAtt EventsApi.ApiId
      Name: admin
      SubscribeAuthModes:
        - AuthType: API_KEY
      PublishAuthModes:
        - AuthType: API_KEY

Uso la autenticación de clave API por simplicidad. En producción, usarías Cognito o IAM para asegurarte de que los usuarios solo puedan suscribirse a sus propios canales. Pero para una fiesta en casa, una clave API incrustada en el frontend es suficiente.

Creo dos espacios de nombres de canales. El espacio de nombres orders es para actualizaciones de pedidos específicas del usuario. Los invitados se suscriben a orders/{their_user_key}. El espacio de nombres admin es para actualizaciones del panel de administración. Me suscribo a admin/new-orders para ver cuándo llegan nuevos pedidos.

Publicación de eventos desde Lambda

Cuando el estado de un pedido cambia, el Lambda publica un evento a AppSync.

def publish_order_update(user_key: str, order: dict, drink_name: str):
    """Publicar actualización del estado del pedido al canal de AppSync Events del usuario."""
    try:
        appsync_client.post(
            action="publish",
            apiId=os.environ["APPSYNC_API_ID"],
            payload=json.dumps({
                "channel": f"orders/{user_key}",
                "events": [json.dumps({
                    "type": "ORDER_STATUS_CHANGED",
                    "order_id": str(order["id"]),
                    "drink_name": drink_name,
                    "status": order["status"],
                    "updated_at": order["updated_at"].isoformat()
                })]
            })
        )
    except Exception as e:
        logger.error(f"Error al publicar: {e}")

La publicación es de tipo "fuego y olvídate". Si falla, registro el error pero no hago que falla todo el proceso de actualización del pedido. El estado del pedido se guarda en la base de datos independientemente. El peor de los casos es que el invitado no recibe una notificación en tiempo real y tiene que actualizar manualmente.

Publicación de eventos de nuevos pedidos al panel de administración

Cuando un invitado crea un pedido, también publico en el canal de administración para que yo lo sepa de inmediato.

def publish_order_created(user_key: str, username: str, order: dict, drink_name: str):
    """Publicar evento de nuevo pedido al canal de administración."""
    try:
        appsync_client.post(
            action="publish",
            apiId=os.environ["APPSYNC_API_ID"],
            payload=json.dumps({
                "channel": "admin/new-orders",
                "events": [json.dumps({
                    "type": "NEW_ORDER",
                    "order_id": str(order["id"]),
                    "user_key": user_key,
                    "username": username,
                    "drink_name": drink_name,
                    "status": "pending",
                    "created_at": order["created_at"].isoformat()
                })]
            })
        )
    except Exception as e:
        logger.error(f"Error al publicar: {e}")

Ahora tanto la aplicación del invitado como el panel de administración reciben actualizaciones en tiempo real. Los invitados ven cómo cambia el estado de su pedido. Yo veo los nuevos pedidos aparecer de inmediato.

Suscripción desde el frontend

En el frontend, uso la biblioteca AWS Amplify para suscribirse a los canales de AppSync Events.

Primero, configuro Amplify con los detalles de la API AppSync Events.

import { Amplify } from 'aws-amplify';

Amplify.configure({
  API: {
    Events: {
      endpoint: 'https://abc123.appsync-api.us-east-1.amazonaws.com/event',
      region: 'us-east-1',
      defaultAuthMode: 'apiKey',
      apiKey: 'da2-xxxxxxxxxxxxxxxxxxxxxxxxxx'
    }
  }
});

Luego me suscribo al canal de pedidos del usuario.

import { events } from '@aws-amplify/api';

function subscribeToOrderUpdates(userKey: string) {
  const channel = events.channel(`orders/${userKey}`);
  
  const subscription = channel.subscribe({
    next: (event) => {
      console.log('Actualización de pedido recibida:', event);
      
      // Actualizar la UI según el tipo de evento
      if (event.type === 'ORDER_STATUS_CHANGED') {
        updateOrderInUI(event.order_id, event.status);
        
        if (event.status === 'ready') {
          showNotification(`¡Tu ${event.drink_name} está listo!`);
        }
      }
    },
    error: (error) => {
      console.error('Error de AppSync Events:', error);
    }
  });
  
  // Devolver función de cancelación de suscripción
  return () => subscription.unsubscribe();
}

La suscripción al canal usa Websockets bajo el capó. Cuando el Lambda publica un evento, AppSync lo envía a todos los clientes conectados suscritos a ese canal. El callback next se ejecuta inmediatamente cuando llegan los eventos.

Gancho React para pedidos en tiempo real

Envolví la lógica de suscripción en un gancho React que administra el ciclo de vida de la conexión.

import { useEffect, useState } from 'react';
import { events } from '@aws-amplify/api';

interface Order {
  id: string;
  drink_id: string;
  drink_name: string;
  status: 'pending' | 'preparing' | 'ready' | 'served';
  created_at: string;
  updated_at: string;
}

export function useRealtimeOrders(userKey: string) {
  const [orders, setOrders] = useState<Order[]>([]);
  const [connected, setConnected] = useState(false);

  useEffect(() => {
    if (!userKey) return;

    const channel = events.channel(`orders/${userKey}`);

    const subscription = channel.subscribe({
      next: (event) => {
        if (event.type === 'ORDER_STATUS_CHANGED') {
          // Actualizar el pedido específico en el estado
          setOrders((prevOrders) => 
            prevOrders.map((order) =>
              order.id === event.order_id
                ? { ...order, status: event.status, updated_at: event.updated_at }
                : order
            )
          );

          // Mostrar notificación para pedidos listos
          if (event.status === 'ready') {
            new Notification(`${event.drink_name} está listo!`);
          }
        }
      },
      error: (error) => {
        console.error('Error de suscripción:', error);
        setConnected(false);
      }
    });

    setConnected(true);

    // Limpiar en desmontaje
    return () => {
      subscription.unsubscribe();
      setConnected(false);
    };
  }, [userKey]);

  return { orders, setOrders, connected };
}

Usar el gancho en un componente es limpio.

function OrdersPage({ userKey }: { userKey: string }) {
  const { orders, setOrders, connected } = useRealtimeOrders(userKey);

  // Cargar pedidos iniciales al montar
  useEffect(() => {
    async function loadOrders() {
      const response = await fetch('/api/orders', {
        headers: { 'Authorization': `Bearer ${accessToken}` }
      });
      const data = await response.json();
      setOrders(data.orders);
    }
    loadOrders();
  }, [userKey, setOrders]);

  return (
    <div>
      <div className="connection-status">
        {connected ? '🟢 En vivo' : '🔴 Fuera de línea'}
      </div>
      {orders.map(order => (
        <OrderCard key={order.id} order={order} />
      ))}
    </div>
  );
}

El gancho maneja automáticamente el ciclo de vida de la suscripción. Cuando el componente se monta, se suscribe. Cuando se desmonta, se cancela la suscripción. El estado connected muestra a los usuarios si están recibiendo actualizaciones en vivo.

Suscripción del panel de administración

El panel de administración se suscribe al canal admin/new-orders.

function AdminDashboard() {
  const [pendingOrders, setPendingOrders] = useState<Order[]>([]);

  useEffect(() => {
    const subscription = events.channel('admin/new-orders').subscribe({
      next: (event) => {
        if (event.type === 'NEW_ORDER') {
          setPendingOrders(prev => [{
            id: event.order_id,
            drink_name: event.drink_name,
            username: event.username,
            status: 'pending',
            created_at: event.created_at
          }, ...prev]);
        }
      }
    });
    return () => subscription.unsubscribe();
  }, []);

  // Renderizar pedidos pendientes...
}

Los nuevos pedidos aparecen instantáneamente sin sondeo.

Reconexión y confiabilidad

AppSync Events maneja la reconexión automáticamente. Si un cliente pierde la conectividad, AppSync intenta reconectarse. Cuando la conexión se restablece, la suscripción se reanuda.

Pero aquí hay una advertencia importante: los eventos publicados mientras el cliente estaba desconectado se pierden. AppSync Events es efímero. No hace cola mensajes ni reproduce eventos perdidos. Debido a esto, trato la base de datos como la fuente de verdad y a AppSync como la capa de velocidad.

Las actualizaciones en tiempo real están allí para hacer que la UI se sienta ágil y receptiva mientras el invitado está usando activamente la aplicación. Si la conexión se cae, la biblioteca Amplify maneja automáticamente la lógica de reconexión. Sin embargo, como sabemos que los mensajes no se hacen cola, la aplicación necesita una forma de ponerse al día.

Cada vez que la aplicación se recupera de un estado fuera de línea o el invitado actualiza manualmente, el frontend debe desencadenar una solicitud rápida GET /orders al API REST. Esto asegura que, incluso si perdieron una notificación en vivo, la pantalla siempre se sincronizará con el estado real de la base de datos.

Es un enfoque del "mejor de ambos mundos". Obtienes el factor "guau" instantáneo de una actualización en vivo cuando las cosas funcionan perfectamente, pero tienes la confiabilidad de una base de datos tradicional para volver a usar si la conexión a Wi-Fi en la fiesta se vuelve un poco irregular.

¿Qué sigue?

La base y el flujo de pedidos están completos. Los invitados pueden navegar por las bebidas, registrarse con códigos de invitación, hacer pedidos y obtener actualizaciones en tiempo real cuando sus bebidas están listas. Yo puedo administrar el menú, ver pedidos y actualizar el estado de los pedidos. Todo funciona, y funciona bien.

Pero la pieza de IA todavía está faltando. En la Parte 3, agregaré las características que hacen de esto un barman IA en lugar de solo un menú digital. Recomendaciones de bebidas impulsadas por IA basadas en preferencias de sabor. Pedido conversacional a través de una interfaz de chat. Generación de imágenes para fotos de bebidas personalizadas usando Amazon Bedrock Nova Canvas. Todo integrado con la base que hemos construido.

La Parte 3 llegará pronto. Sígueme en LinkedIn para no perderte nada.

Palabras finales

Las actualizaciones en tiempo real solían ser difíciles. Websockets, administración de conexiones, transmisión, escalado. Ahora AppSync Events lo maneja todo. Publicas desde Lambda, los clientes se suscriben, y AppSync enruta los eventos. Eso es todo.

El patrón de JWT autofirmados es igualmente sencillo. Genera un par de claves RSA, firma tokens en un Lambda, verifica en otro. Sin la sobrecarga de Cognito para usuarios temporales, solo criptografía simple.

El resultado es un sistema de pedidos completo que se siente rápido y receptivo. Los invitados se registran en segundos, hacen pedidos con un toque y reciben una notificación en el momento en que su bebida está lista. Sin sondeo, sin actualización manual, sin fricción.

El código completo está en GitHub. Visita mis otras publicaciones en jimmydqv.com para más patrones sin servidor.

¡Ahora ve a construir!