Construindo um Bartender IA Sem Servidor - Parte 2: Registro de convidados e atualizações ao vivo de pedidos

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

Este arquivo foi traduzido automaticamente por IA, erros podem ocorrer

Na Parte 1, construí a base para o assistente de coquetéis com IA. Os convidados podem navegar pelo cardápio de bebidas, eu posso gerenciar bebidas e enviar imagens, e o sistema gera prompts de imagens com IA via EventBridge. Mas há um problema: os convidados ainda não podem pedir nada.

Isso é o que estamos construindo nesta postagem. Preciso de três coisas para completar o fluxo de pedidos. Primeiro, os convidados precisam poder se registrar sem criar contas completas; lembre-se, este projeto foi construído para uma pequena festa em casa, e pedir que as pessoas verifiquem e-mails e definam senhas é demais. Segundo, ainda precisamos de um fluxo de colocação de pedidos autenticado para que as pessoas possam enviar e rastrear seus pedidos de bebidas. Terceiro, quero atualizações em tempo real para que, quando eu marcar uma bebida como pronta, os convidados vejam instantaneamente em seus telefones em vez de atualizarem constantemente.

Então vamos começar a construir!

Por que não deixar qualquer um fazer pedidos?

A solução mais fácil seria simplesmente deixar o ponto de extremidade do pedido completamente público, com uma chave de API. Qualquer um pode enviar pedidos sem autenticação. Mas isso cria alguns problemas que eu não quero ter.

Sem identificação do usuário, não posso rastrear quem pediu o quê. Se três pessoas pedirem Negroni, como eu sei qual é qual quando estiverem prontos?

Do ponto de vista de segurança, um ponto de extremidade completamente aberto está pedindo problemas. Qualquer um na internet poderia enviar spam de pedidos. Mesmo em uma festa em casa, quero algum controle básico.

A solução precisa equilibrar segurança com conveniência. Os convidados não devem passar por obstáculos, mas preciso de controle suficiente para tornar o sistema utilizável.

Registro de convidados com códigos de convite

Para administradores, ou seja, eu, uso o Amazon Cognito com autenticação completa: e-mail, senha, tokens JWT, tudo. Isso faz sentido para eu gerenciar o cardápio de bebidas e visualizar todos os pedidos. Mas é exagero para convidados da festa.

Eu queria que fosse o mais simples possível, como um convite para uma festa. A ideia era que os convidados digitassem um código de registro, inserissem seu nome e estivessem prontos para usar.

Então, como criei essa solução? Antes da festa, gero um código de registro. Este código tem uma data de expiração, válida para o dia da festa, e um número definido de vezes que pode ser usado, tornando possível criar um código válido para o número esperado de convidados mais alguns extras. Posso imprimir o código como um QR code escaneável, tornando ainda mais fácil o registro. Quando os convidados chegarem, eles escaneiam ou digitam o código junto com o nome escolhido. O sistema valida o código, verifica se não atingiu o número máximo de usos ou se está expirado, cria uma conta de usuário, incrementa a contagem de usos do código e retorna tokens JWT. O convidado pode agora fazer pedidos. Mas espere — tokens JWT? Não disse que não íamos usar tokens JWT e fazer login? Fique tranquilo, estou chegando lá.

É assim que o registro foi construído.

Imagem da arquitetura de registro

O esquema do banco de dados

O sistema de registro adiciona duas novas tabelas ao esquema: app_users para convidados e registration_codes para os códigos de convite.

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
);

Chamei a tabela de app_users em vez de apenas users pois cocktails.users já estava em uso para autenticação de administradores. Mantê-los separados deixa tudo muito mais claro.

O user_key é um UUID que identifica o convidado, para que possamos ter dois "Sam" na festa.

Os códigos de registro também usam UUIDs como valor de code; isso os torna "indivinháveis" — bem, talvez seja um exagero, mas difíceis de adivinhar de qualquer maneira. Cada código tem um limite de max_uses definido no momento da criação, permitindo que o mesmo código registre vários convidados. O use_count rastreia quantas vezes o código foi usado.

Criando códigos de registro

Eu, como administrador, crio códigos de registro antes da festa através de um ponto de extremidade de administrador. A função Lambda gera um UUID e define o tempo de expiração fornecido.

def handler(event, context):
    """Ponto de extremidade de administrador - Criar 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"]
    })

O código gerado parece algo assim: f47ac10b-58cc-4372-a567-0e02b2c3d479. Agora posso compartilhar isso com os convidados como quiser: mensagem de texto, e-mail ou impresso como um QR code. Para uma festa em casa com 20 convidados, posso criar um código com max_uses: 25 para considerar convidados adicionais. Para um evento maior, posso criar vários códigos por mesa ou seção.

O fluxo de registro

Quando um convidado se registra, o sistema verifica o código fornecido e cria a conta. A parte interessante aqui é que essa validação ocorre em duas etapas diferentes. Começa com um autorizador Lambda que é executado antes mesmo da solicitação chegar à lógica principal, e então o ponto de extremidade em si verifica tudo novamente. O trabalho principal do autorizador é garantir que o código seja real e ainda não tenha atingido seu limite de usos.

def handler(event, context):
    """Autorizador Lambda de código 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})

Como o autorizador funciona no nível do API Gateway, um código inválido nem chega ao Lambda de registro. Essa configuração é eficiente porque evita que o Lambda de registro seja executado para registros falsos, impedindo floods. Após essa verificação inicial, o ponto de extremidade de registro termina o trabalho criando o usuário na tabela e aumentando a contagem de uso do código.

def handler(event, context):
    """POST /register - Criar novo usuário convidado."""
    code = event["requestContext"]["authorizer"]["registrationCode"]
    username = json.loads(event["body"]).get("username", "").strip()
    
    # Validar nome de usuário
    if not username or len(username) < 2 or len(username) > 50:
        return response(400, {"error": "O nome de usuário deve ter entre 2 e 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 do 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)
    })

O ponto de extremidade de registro é idempotente de uma maneira importante. Se o autorizador passar, sabemos que o código é válido e não atingiu seu limite. Se o Lambda falhar no meio do caminho devido a um tempo limite ou erro, a contagem de uso não é incrementada, então o convidado pode tentar novamente. Uma vez que o Lambda confirma a transação, a contagem de uso aumenta. Quando use_count atingir max_uses, o autorizador rejeitará futuras tentativas de registro com esse código.

Tokens JWT autoassinados

Agora, de volta aos tokens JWT — eu disse que voltaríamos para isso.

Após um registro bem-sucedido, os convidados recebem tokens JWT. Mas ao contrário do fluxo de administrador, que usa tokens emitidos pelo Cognito, os tokens de convidados são autoassinados. Eu os gero usando pares de chaves RSA armazenados no AWS Secrets Manager.

Por que autoassinados em vez do Cognito?

O Cognito é um ótimo serviço, mas é projetado para usuários de longa duração. Se eu o usasse aqui, precisaria criar contas no user pool, gerenciar senhas e lidar com verificações de e-mail. Isso é muita sobrecarga para um convidado que está lá apenas por algumas horas.

Usar JWTs autoassinados me dá a flexibilidade de controlar exatamente o que está no token e por quanto tempo ele permanece ativo. Os convidados não precisam de coisas como autenticação em dois fatores ou redefinição de senhas. Eles só precisam de uma maneira de provar que se registraram para poder fazer pedidos durante a noite.

O problema é que tenho que lidar com a validação eu mesmo. Preciso gerenciar um par de chaves RSA e escrever a lógica de verificação em um autorizador Lambda. No entanto, o processo não é tão complicado, e gosto de ter esse nível de controle sobre todo o fluxo.

Criando tokens de acesso e atualização

O Lambda de registro gera dois tokens: um token de acesso e um token de atualização.

def generate_access_token(user: dict) -> str:
    """Gerar token de acesso JWT assinado com 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:
    """Gerar token de atualização criptograficamente aleatório."""
    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

O token de acesso é um JWT com expiração de 4 horas. Quatro horas são suficientes para uma festa, mas curtas o suficiente para que tokens roubados não durem para sempre. A carga inclui user_key e username para que as funções Lambda downstream saibam quem está fazendo as solicitações.

O token de atualização é diferente. Não é um JWT, apenas uma string criptograficamente aleatória. Eu armazeno o hash SHA-256 no banco de dados junto com sua expiração, 2 dias. Isso permite que os convidados mantenham sua sessão ativa através de múltiplas reaberturas do aplicativo sem precisar se registrar novamente.

A tabela de tokens de atualização

Os tokens de atualização são armazenados separadamente dos usuários.

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 que hash do token de atualização em vez de armazená-lo diretamente? Porque os tokens de atualização são de longa duração — se podemos chamar 2 dias de longa duração. Se alguém obtiver acesso ao banco de dados, poderia usar tokens armazenados para se passar por usuários. Ao hashear, garantimos que, mesmo com acesso ao banco de dados, o invasor não possa reconstruir os tokens originais.

O campo is_revoked permite-me invalidar tokens se necessário. O campo device_info armazena metadados como o user agent do navegador, útil para depuração ou mostrar aos usuários onde estão conectados.

Ponto de extremidade de atualização de token

Quando o token de acesso expira, o frontend chama o ponto de extremidade de atualização com o token de atualização.

def handler(event, context):
    """POST /auth/refresh - Trocar token de atualização por novo token de acesso."""
    refresh_token = json.loads(event["body"]).get("refresh_token", "")
    if not refresh_token:
        return response(400, {"error": "token de atualização obrigatório"})
    
    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 atualização inválido ou 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"]
        })
    })

Esse padrão é a atualização de token estilo OAuth2 padrão. O token de atualização nunca expira contanto que seja usado periodicamente. O token de acesso expira com frequência, mas pode ser renovado sem reautenticação.

O autorizador de usuário

Os pontos de extremidade de pedidos exigem autenticação. O autorizador de usuário Lambda valida os tokens de acesso JWT autoassinados.

def handler(event, context):
    """Autorizador JWT de usuário - valida tokens autoassinados."""
    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 eu disse, você pode ver, isso não é tão complexo. O autorizador Lambda, invocado pelo API Gateway, carrega a chave pública do Secrets Manager, verifica a assinatura do token com a biblioteca PyJWT e verifica a expiração. Se tudo passar, extrai o user_key e username e os passa para a função Lambda downstream através do contexto do autorizador.

As funções Lambda downstream não precisam analisar os tokens JWT elas mesmas. O contexto está disponível em event["requestContext"]["authorizer"].

Armazenando em cache a chave pública

Uma otimização que vale a pena mencionar é armazenar em cache a chave pública. Carregá-la do Secrets Manager a cada solicitação seria lento e caro. Em vez disso, eu a cacho em uma variável global.

_jwt_public_key = None

def get_public_key_from_secrets_manager() -> str:
    """Obter chave pública JWT do Secrets Manager com cache."""
    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

Os ambientes de execução Lambda são reutilizados entre invocações. A primeira invocação carrega a chave e a armazena em cache. Invocações subsequentes no mesmo ambiente de execução reutilizam a chave em cache. Isso reduz as chamadas à API do Secrets Manager e melhora o tempo de latência em muitos casos — não é perfeito, mas com certeza é bom o suficiente.

Fazendo pedidos

Com o registro e a autenticação concluídos, os convidados agora podem fazer pedidos. O ponto de extremidade de pedidos está protegido pelo autorizador de usuário.

A tabela de pedidos

Os pedidos são armazenados na tabela 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
);

O user_key se liga ao app_users.user_key. O campo status rastreia o ciclo de vida do pedido: pending, preparing, ready ou served. O carimbo de data/hora completed_at é definido quando o status atinge served.

Criando um pedido

O Lambda de criação de pedido é simples. Ele verifica se a bebida está disponível — eu não quero que os convidados peçam um Negroni se eu ficar sem Campari. Em seguida, cria o registro do pedido e retorna os detalhes do pedido.

def handler(event, context):
    """POST /orders - Criar novo pedido para usuário 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 não 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()
        }
    })

Listando pedidos do usuário

Os convidados também podem buscar seu histórico completo de pedidos.

def handler(event, context):
    """GET /orders - Listar pedidos para usuário 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()})

A consulta junta orders com drinks para incluir o nome da bebida na resposta. Os convidados veem "Negroni - pendente" em vez de um UUID.

Atualizações de status de pedido

Esta é a parte que estou muito animado e um dos elementos-chave nesta parte. Enquanto eu estava construindo o aplicativo, percebi que havia um problema potencial que criaria uma má experiência do usuário. Se você fizer um pedido e não tiver como saber o progresso, você acaba vindo até mim perguntando pelo seu drink. Esse tipo de verificação manual realmente anula o propósito do aplicativo.

Eu sabia que precisava de algum tipo de sistema ao vivo desde o início. No momento em que eu marcar uma bebida como pronta no meu lado, o aplicativo no telefone do convidado deve atualizar e notificar. Sem polling e sem botão de atualização.

Atualizações em tempo real geralmente são apenas pub/sub

Quando as pessoas falam sobre "atualizações em tempo real", o que elas geralmente querem dizer de uma perspectiva técnica é um modelo de Pub/Sub (Publicar/Assinar). Este é exatamente o motivo pelo qual escolhi o AWS AppSync Events — é um serviço poderoso de pub/sub que me permitirá enviar atualizações para os usuários.

Em um aplicativo web padrão, o cliente precisa pedir dados (solicitação/resposta). Em um configuração de pub/sub, o cliente se inscreve em um tópico específico, como orders/{user_id}. Então, sempre que uma mudança ocorre no servidor, o backend publica uma mensagem para esse tópico. O broker pub/sub (no nosso caso, o AppSync Events) lida com o trabalho pesado de descobrir quem está ouvindo e enviar essa mensagem instantaneamente para eles.

AppSync Events vs EventBridge

Na Parte 1, usei o Amazon EventBridge para fluxos de eventos como gerar prompts de imagens com IA. O EventBridge é um ótimo serviço quando se constrói aplicações baseadas em eventos. Mas o EventBridge não envia eventos para aplicações cliente.

Para implementar uma configuração de pub/sub de cliente, precisarei usar Websockets de alguma forma. A maneira mais fácil que encontrei para obter uma configuração de pub/sub sobre websockets é usar o AppSync Events — agora, não se deixe enganar pela parte do AppSync no nome. Isso é muito diferente do AppSync API, que é uma implementação gerenciada de API GraphQL. Acho um pouco infeliz que a implementação de pub/sub tenha pousado sob o AppSync também.

O AppSync Events lida com conexões, assinaturas e transmissão automaticamente. Eu apenas publico eventos do Lambda, e o AppSync os roteia para os clientes assinados.

Conceitos do AppSync Events

O AppSync Events é construído em torno de três conceitos.

Canais são como tópicos em um sistema de pub/sub. Os clientes se inscrevem em canais para receber eventos. No meu caso, cada usuário se inscreve em orders/{user_key} para obter atualizações sobre seus pedidos.

Espaços de nome de canal agrupam canais relacionados. Eu crio um espaço de nome chamado orders, e os canais são criados dinamicamente dentro dele. Quando um usuário se inscreve em orders/abc-123, o AppSync cria automaticamente esse canal se ele não existir.

Eventos são mensagens publicadas em canais. Quando atualizo o status de um pedido, publico um evento no canal desse usuário. O AppSync o transmite para todos os clientes conectados assinados a esse canal.

Visão geral do design de pub/sub

A configuração de pub/sub com o AppSync Events parece algo assim.

Imagem da arquitetura de registro

Configurando o AppSync Events

O CloudFormation cria uma API do AppSync Events com espaços de nome de canal.

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: Chave de API para AppSync Events
      Expires: 1767225600  # Expiração no futuro distante

  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

Eu uso autenticação de chave de API por simplicidade. Na produção, você usaria Cognito ou IAM para garantir que os usuários só possam se inscrever em seus próprios canais. Mas para uma festa em casa, uma chave de API embutida no frontend é suficiente.

Crio dois espaços de nome de canal. O espaço de nome orders é para atualizações de pedidos específicas do usuário. Os convidados se inscrevem em orders/{their_user_key}. O espaço de nome admin é para atualizações do painel de administrador. Eu me inscrevo em admin/new-orders para ver quando novos pedidos chegam.

Publicando eventos do Lambda

Quando o status de um pedido muda, o Lambda publica um evento para o AppSync.

def publish_order_update(user_key: str, order: dict, drink_name: str):
    """Publicar atualização de status de pedido no canal do AppSync Events do usuário."""
    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"Falha ao publicar: {e}")

A publicação é do tipo "fire-and-forget". Se falhar, eu registro o erro, mas não falho toda a atualização de pedido. O status do pedido é salvo no banco de dados independentemente. O pior caso é o convidado não receber uma notificação em tempo real e ter que atualizar manualmente.

Publicando eventos de novos pedidos para o painel de administrador

Quando um convidado cria um pedido, eu também publico no canal de administrador para que eu saiba imediatamente.

def publish_order_created(user_key: str, username: str, order: dict, drink_name: str):
    """Publicar evento de novo pedido no canal de administrador."""
    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"Falha ao publicar: {e}")

Agora, tanto o aplicativo do convidado quanto o painel de administrador recebem atualizações em tempo real. Os convidados veem o status do pedido mudar. Eu vejo novos pedidos aparecerem instantaneamente.

Assinando do frontend

No frontend, uso a biblioteca AWS Amplify para assinar canais do AppSync Events.

Primeiro, configure o Amplify com os detalhes da API do 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'
    }
  }
});

Em seguida, assine o canal de pedidos do usuário.

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

function subscribeToOrderUpdates(userKey: string) {
  const channel = events.channel(`orders/${userKey}`);
  
  const subscription = channel.subscribe({
    next: (event) => {
      console.log('Atualização de pedido recebida:', event);
      
      // Atualizar UI com base no tipo de evento
      if (event.type === 'ORDER_STATUS_CHANGED') {
        updateOrderInUI(event.order_id, event.status);
        
        if (event.status === 'ready') {
          showNotification(`Seu ${event.drink_name} está pronto!`);
        }
      }
    },
    error: (error) => {
      console.error('Erro no AppSync Events:', error);
    }
  });
  
  // Retornar função de cancelamento de assinatura
  return () => subscription.unsubscribe();
}

A assinatura de canal usa Websockets sob o capô. Quando o Lambda publica um evento, o AppSync o encaminha para todos os clientes conectados assinados a esse canal. O callback next é acionado imediatamente quando os eventos chegam.

Hook React para pedidos em tempo real

Eu envolvi a lógica de assinatura em um hook React que gerencia o ciclo de vida da conexão.

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') {
          // Atualizar o pedido específico no estado
          setOrders((prevOrders) => 
            prevOrders.map((order) =>
              order.id === event.order_id
                ? { ...order, status: event.status, updated_at: event.updated_at }
                : order
            )
          );

          // Mostrar notificação para pedidos prontos
          if (event.status === 'ready') {
            new Notification(`${event.drink_name} está pronto!`);
          }
        }
      },
      error: (error) => {
        console.error('Erro de assinatura:', error);
        setConnected(false);
      }
    });

    setConnected(true);

    // Limpeza ao desmontar
    return () => {
      subscription.unsubscribe();
      setConnected(false);
    };
  }, [userKey]);

  return { orders, setOrders, connected };
}

Usar o hook em um componente é simples.

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

  // Carregar pedidos iniciais na montagem
  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 ? '🟢 Ao vivo' : '🔴 Offline'}
      </div>
      {orders.map(order => (
        <OrderCard key={order.id} order={order} />
      ))}
    </div>
  );
}

O hook gerencia automaticamente o ciclo de vida da assinatura. Quando o componente é montado, ele se inscreve. Quando é desmontado, ele cancela a assinatura. O estado connected mostra aos usuários se eles estão recebendo atualizações ao vivo.

Assinatura do painel de administrador

O painel de administrador se inscreve no 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 pendentes...
}

Novos pedidos aparecem instantaneamente sem polling.

Reconexão e confiabilidade

O AppSync Events lida com a reconexão automaticamente. Se um cliente perder a conectividade, o AppSync tenta se reconectar. Quando a conexão é restabelecida, a assinatura retoma.

Mas há uma importante ressalva: Eventos publicados enquanto o cliente estava desconectado são perdidos. O AppSync Events é efêmero. Ele não coloca em fila mensagens ou reproduz eventos perdidos. Por isso, trato o banco de dados como a fonte da verdade e o AppSync como a camada de velocidade.

As atualizações em tempo real estão lá para tornar a interface rápida e responsiva enquanto o convidado está ativamente usando o aplicativo. Se a conexão cair, a biblioteca Amplify lida com a lógica de reconexão automaticamente. No entanto, como sabemos que as mensagens não são colocadas em fila, o aplicativo precisa de uma maneira de sincronizar.

Sempre que o aplicativo se recuperar de um estado offline ou o convidado atualizar manualmente, o frontend deve acionar uma solicitação rápida GET /orders para a API REST. Isso garante que, mesmo que eles tenham perdido uma notificação ao vivo, a tela sempre se sincronizará com o estado real do banco de dados.

É uma abordagem do "melhor dos dois mundos". Você obtém o fator "uau" instantâneo de uma atualização ao vivo quando as coisas estão funcionando perfeitamente, mas tem a confiabilidade de um banco de dados tradicional para cair back se a conexão Wi-Fi na festa ficar um pouco instável.

E agora?

A base e o fluxo de pedidos estão completos. Os convidados podem navegar pelas bebidas, se registrar com códigos de convite, fazer pedidos e receber atualizações em tempo real quando suas bebidas estiverem prontas. Eu posso gerenciar o cardápio, visualizar pedidos e atualizar o status dos pedidos. Tudo funciona, e funciona bem.

Mas a peça de IA ainda está faltando. Na Parte 3, adicionarei os recursos que farão disso um bartender com IA em vez de apenas um cardápio digital. Recomendações de bebidas com IA baseadas em preferências de gosto. Encomenda conversacional através de uma interface de chat. Geração de imagens para fotos de bebidas personalizadas usando o Amazon Bedrock Nova Canvas. Tudo integrado com a base que construímos.

A Parte 3 está chegando em breve. Siga-me no LinkedIn para não perder.

Palavras finais

As atualizações em tempo real costumavam ser difíceis. Websockets, gerenciamento de conexões, transmissão, dimensionamento. Agora o AppSync Events lida com tudo isso. Você publica do Lambda, os clientes se inscrevem e o AppSync roteia os eventos. É isso.

O padrão de JWT autoassinado também é igualmente simples. Gere um par de chaves RSA, assine tokens em um Lambda, verifique-os em outro. Sem sobrecarga do Cognito para usuários temporários, apenas criptografia simples.

O resultado é um sistema de pedidos completo que parece rápido e responsivo. Os convidados se registram em segundos, fazem pedidos com um toque e são notificados no momento em que sua bebida está pronta. Sem polling, sem atualização manual, sem atrito.

O código completo está no GitHub. Confira meus outros posts em jimmydqv.com para mais padrões serverless.

Agora vá construir!