From afb4ff7ba46b63d7fc1b80cc6b2c7dfb2d377170 Mon Sep 17 00:00:00 2001 From: maxsonferovante Date: Sat, 5 Sep 2026 09:30:48 -0300 Subject: [PATCH 1/4] feat: refactor certificate repository for DynamoDB single-table design - Add dynamodb_keys.py with PK/SK/GSI key generation helpers - Refactor CertificateRepository for single-table pattern: - Uses GSI1 (UUID), GSI2 (email), GSI3 (product), GSI4 (success) - Add EntityType filter to GSI queries - Update config to return single table name --- src/infrastructure/aws/dynamodb_keys.py | 96 ++++++++++++ src/infrastructure/config/config.py | 62 ++------ .../repository/certificate_repository_impl.py | 139 ++++++++++-------- 3 files changed, 185 insertions(+), 112 deletions(-) create mode 100644 src/infrastructure/aws/dynamodb_keys.py diff --git a/src/infrastructure/aws/dynamodb_keys.py b/src/infrastructure/aws/dynamodb_keys.py new file mode 100644 index 0000000..4a6cba4 --- /dev/null +++ b/src/infrastructure/aws/dynamodb_keys.py @@ -0,0 +1,96 @@ +from enum import Enum +from typing import Optional + + +class EntityType(str, Enum): + ORDER = "ORDER" + PRODUCT = "PRODUCT" + PARTICIPANT = "PARTICIPANT" + CERTIFICATE = "CERTIFICATE" + + +def pk(entity_type: EntityType, entity_id: str | int) -> str: + return f"{entity_type.value}#{entity_id}" + + +def sk(entity_type: EntityType, entity_id: str | int) -> str: + return f"{entity_type.value}#{entity_id}" + + +def sk_order(order_id: int) -> str: + return sk(EntityType.ORDER, order_id) + + +def sk_product(product_id: int) -> str: + return sk(EntityType.PRODUCT, product_id) + + +def sk_participant(participant_id: str) -> str: + return sk(EntityType.PARTICIPANT, participant_id) + + +def sk_certificate(order_id: int) -> str: + return sk(EntityType.CERTIFICATE, order_id) + + +def gsi1pk_email(email: str) -> str: + return f"EMAIL#{email.lower().strip()}" + + +def gsi1sk_participant(participant_id: str) -> str: + return f"PARTICIPANT#{participant_id}" + + +def gsi1sk_order(order_id: int) -> str: + return f"ORDER#{order_id}" + + +def gsi1sk_certificate(order_id: int) -> str: + return f"CERTIFICATE#{order_id}" + + +def gsi2pk_product(product_id: int) -> str: + return f"PRODUCT#{product_id}" + + +def gsi2sk_order(order_id: int) -> str: + return f"ORDER#{order_id}" + + +def gsi2sk_certificate(order_id: int) -> str: + return f"CERTIFICATE#{order_id}" + + +def gsi3pk_success(success: bool) -> str: + return f"SUCCESS#{1 if success else 0}" + + +def gsi3sk_certificate(order_id: int) -> str: + return f"CERTIFICATE#{order_id}" + + +def gsi4pk_city(city: str) -> str: + return f"CITY#{city}" + + +def gsi4sk_participant(participant_id: str) -> str: + return f"PARTICIPANT#{participant_id}" + + +def gsi1pk_cert_id(cert_id: str) -> str: + return f"CERT#{cert_id}" + + +def gsi1sk_cert(cert_id: str) -> str: + return f"CERTIFICATE#{cert_id}" + + +def parse_pk(pk: str) -> tuple[EntityType, str]: + parts = pk.split("#", 1) + if len(parts) != 2: + raise ValueError(f"Invalid PK format: {pk}") + return EntityType(parts[0]), parts[1] + + +def parse_sk(sk: str) -> tuple[EntityType, str]: + return parse_pk(sk) diff --git a/src/infrastructure/config/config.py b/src/infrastructure/config/config.py index 4c5b4f8..e89249c 100644 --- a/src/infrastructure/config/config.py +++ b/src/infrastructure/config/config.py @@ -18,64 +18,24 @@ class Config: env_file_encoding = "utf-8" @property - def dynamodb_tables(self) -> Dict[str, Dict[str, str]]: - """ - Retorna as configurações das tabelas do DynamoDB baseadas no ambiente. - Segue o padrão da infraestrutura Terraform criada. - """ + def dynamodb_table(self) -> Dict[str, str]: base_name = f"{self.PROJECT_NAME}" environment = self.ENVIRONMENT return { - "certificates": { - "name": f"{base_name}-certificates-{environment}", - "arn": f"arn:aws:dynamodb:{self.REGION}:*:table/{base_name}-certificates-{environment}" - }, - "orders": { - "name": f"{base_name}-orders-{environment}", - "arn": f"arn:aws:dynamodb:{self.REGION}:*:table/{base_name}-orders-{environment}" - }, - "participants": { - "name": f"{base_name}-participants-{environment}", - "arn": f"arn:aws:dynamodb:{self.REGION}:*:table/{base_name}-participants-{environment}" - }, - "products": { - "name": f"{base_name}-products-{environment}", - "arn": f"arn:aws:dynamodb:{self.REGION}:*:table/{base_name}-products-{environment}" - } + "name": f"{base_name}-{environment}", + "arn": f"arn:aws:dynamodb:{self.REGION}:*:table/{base_name}-{environment}" } - def get_table_name(self, entity: str) -> str: - """ - Retorna o nome da tabela para uma entidade específica. - - Args: - entity: Nome da entidade (certificates, orders, participants, products) - - Returns: - str: Nome da tabela no DynamoDB - """ - tables = self.dynamodb_tables - if entity not in tables: - raise ValueError(f"Entidade '{entity}' não encontrada. Entidades disponíveis: {list(tables.keys())}") - - return tables[entity]["name"] + @property + def dynamodb_tables(self) -> Dict[str, Dict[str, str]]: + return {"single": self.dynamodb_table} - def get_table_arn(self, entity: str) -> str: - """ - Retorna o ARN da tabela para uma entidade específica. - - Args: - entity: Nome da entidade (certificates, orders, participants, products) - - Returns: - str: ARN da tabela no DynamoDB - """ - tables = self.dynamodb_tables - if entity not in tables: - raise ValueError(f"Entidade '{entity}' não encontrada. Entidades disponíveis: {list(tables.keys())}") - - return tables[entity]["arn"] + def get_table_name(self, entity: str = None) -> str: + return self.dynamodb_table["name"] + + def get_table_arn(self, entity: str = None) -> str: + return self.dynamodb_table["arn"] config = Config() diff --git a/src/infrastructure/repository/certificate_repository_impl.py b/src/infrastructure/repository/certificate_repository_impl.py index f1da6e2..ceaea06 100644 --- a/src/infrastructure/repository/certificate_repository_impl.py +++ b/src/infrastructure/repository/certificate_repository_impl.py @@ -1,9 +1,20 @@ import logging +import uuid from typing import List, Optional from src.domain.entity.certificate import Certificate from src.domain.repository.certificate_repository import CertificateRepository from src.infrastructure.aws.dynamodb_service import DynamoDBService +from src.infrastructure.aws.dynamodb_keys import ( + EntityType, + pk, + sk, + gsi1pk_email, + gsi2pk_product, + gsi2sk_certificate, + gsi3pk_success, + gsi3sk_certificate, +) logger = logging.getLogger() logger.setLevel(logging.INFO) @@ -15,30 +26,20 @@ def _normalize_email(email: Optional[str]) -> Optional[str]: return email.strip().lower() -def _email_product_key(email: Optional[str], product_id: Optional[int]) -> Optional[str]: - normalized_email = _normalize_email(email) - if not normalized_email or product_id is None: - return None - return f"{normalized_email}#{product_id}" - - def _success_flag(success: Optional[bool]) -> int: return 1 if success else 0 class CertificateRepositoryImpl(CertificateRepository): - def __init__(self, dynamodb_service: DynamoDBService, entity_name: str = "certificates"): + def __init__(self, dynamodb_service: DynamoDBService): self.dynamodb_service = dynamodb_service - self.entity_name = entity_name def create(self, entity: Certificate) -> Certificate: try: item = self._prepare_item(entity) - self.dynamodb_service.put_item(item, self.entity_name) - + self.dynamodb_service.put_item(item, "single") logger.info(f"Certificado criado com sucesso: {entity.id}") return entity - except Exception as e: logger.error(f"Erro ao criar certificado: {str(e)}") raise @@ -46,16 +47,18 @@ def create(self, entity: Certificate) -> Certificate: def get_by_id(self, entity_id: str) -> Optional[Certificate]: try: return self.find_by_id(entity_id) - except Exception as e: logger.error(f"Erro ao buscar certificado por ID {entity_id}: {str(e)}") raise def get_all(self) -> List[Certificate]: try: - items = self.dynamodb_service.scan_table(self.entity_name) + items = self.dynamodb_service.query_table( + "single", + "EntityType = :entity_type", + {":entity_type": EntityType.CERTIFICATE.value}, + ) return [Certificate(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar todos os certificados: {str(e)}") raise @@ -68,8 +71,9 @@ def update(self, entity_id: str, entity: Certificate) -> Optional[Certificate]: return None update_data = self._prepare_item(entity) + update_data.pop("PK", None) + update_data.pop("SK", None) update_data.pop("id", None) - update_data.pop("order_id", None) update_expression = "SET " expression_values = {} @@ -84,10 +88,11 @@ def update(self, entity_id: str, entity: Certificate) -> Optional[Certificate]: update_expression = update_expression.rstrip(", ") response = self.dynamodb_service.update_item( - {"order_id": existing_certificate.order_id}, + {"PK": pk(EntityType.CERTIFICATE, existing_certificate.order_id), + "SK": sk(EntityType.CERTIFICATE, existing_certificate.order_id)}, update_expression, expression_values, - self.entity_name, + "single", expression_attribute_names=expression_names, ) @@ -95,7 +100,6 @@ def update(self, entity_id: str, entity: Certificate) -> Optional[Certificate]: result_dict = self.dynamodb_service._convert_from_dynamodb_format(response["Attributes"]) return Certificate(**result_dict) return None - except Exception as e: logger.error(f"Erro ao atualizar certificado {entity_id}: {str(e)}") raise @@ -107,10 +111,13 @@ def delete(self, entity_id: str) -> bool: logger.warning(f"Certificado {entity_id} não encontrado para remoção") return False - self.dynamodb_service.delete_item({"order_id": certificate.order_id}, self.entity_name) + self.dynamodb_service.delete_item( + {"PK": pk(EntityType.CERTIFICATE, certificate.order_id), + "SK": sk(EntityType.CERTIFICATE, certificate.order_id)}, + "single" + ) logger.info(f"Certificado {entity_id} removido com sucesso") return True - except Exception as e: logger.error(f"Erro ao remover certificado {entity_id}: {str(e)}") return False @@ -118,7 +125,6 @@ def delete(self, entity_id: str) -> bool: def exists(self, entity_id: str) -> bool: try: return self.find_by_id(entity_id) is not None - except Exception as e: logger.error(f"Erro ao verificar existência do certificado {entity_id}: {str(e)}") return False @@ -126,27 +132,29 @@ def exists(self, entity_id: str) -> bool: def find_by_id(self, entity_id: str) -> Optional[Certificate]: try: items = self.dynamodb_service.query_table( - self.entity_name, - "id = :id", - {":id": str(entity_id)}, - index_name="certificate_id_idx", + "single", + "GSI1PK = :gsi1pk", + {":gsi1pk": f"CERT#{entity_id}"}, + index_name="GSI1", ) - if items: return Certificate(**items[0]) return None - except Exception as e: logger.error(f"Erro ao buscar certificado por ID {entity_id}: {str(e)}") raise def get_by_order_id(self, order_id: int) -> List[Certificate]: try: - item = self.dynamodb_service.get_item({"order_id": order_id}, self.entity_name) - if not item: + items = self.dynamodb_service.query_table( + "single", + "PK = :pk AND SK = :sk", + {":pk": pk(EntityType.CERTIFICATE, order_id), + ":sk": sk(EntityType.CERTIFICATE, order_id)}, + ) + if not items: return [] - return [Certificate(**item)] - + return [Certificate(**items[0])] except Exception as e: logger.error(f"Erro ao buscar certificados por order_id {order_id}: {str(e)}") raise @@ -154,28 +162,28 @@ def get_by_order_id(self, order_id: int) -> List[Certificate]: def get_by_participant_email(self, email: str) -> List[Certificate]: try: items = self.dynamodb_service.query_table( - self.entity_name, - "participant_email = :email", - {":email": _normalize_email(email)}, - index_name="certificates_by_email_idx", + "single", + "GSI2PK = :gsi2pk AND begins_with(GSI2SK, :sk_prefix)", + {":gsi2pk": gsi1pk_email(email), ":sk_prefix": "CERTIFICATE#"}, + index_name="GSI2", scan_index_forward=False, ) return [Certificate(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar certificados por email {email}: {str(e)}") raise def get_by_email_and_product_id(self, email: str, product_id: int) -> List[Certificate]: try: + normalized_email = _normalize_email(email) items = self.dynamodb_service.query_table( - self.entity_name, - "participant_email_product_key = :email_product_key", - {":email_product_key": _email_product_key(email, product_id)}, - index_name="certificates_by_email_product_idx", + "single", + "GSI2PK = :gsi2pk AND begins_with(GSI2SK, :sk_prefix)", + {":gsi2pk": gsi1pk_email(normalized_email), ":sk_prefix": "CERTIFICATE#"}, + index_name="GSI2", scan_index_forward=False, ) - certificates = [Certificate(**item) for item in items] + certificates = [Certificate(**item) for item in items if item.get("product_id") == product_id] logger.info( "Encontrados %s certificados para email %s e product_id %s", len(certificates), @@ -183,7 +191,6 @@ def get_by_email_and_product_id(self, email: str, product_id: int) -> List[Certi product_id, ) return certificates - except Exception as e: logger.error(f"Erro ao buscar certificados por email {email} e product_id {product_id}: {str(e)}") raise @@ -191,14 +198,13 @@ def get_by_email_and_product_id(self, email: str, product_id: int) -> List[Certi def get_by_product_id(self, product_id: int) -> List[Certificate]: try: items = self.dynamodb_service.query_table( - self.entity_name, - "product_id = :product_id", - {":product_id": product_id}, - index_name="certificates_by_product_idx", + "single", + "GSI3PK = :gsi3pk AND begins_with(GSI3SK, :sk_prefix)", + {":gsi3pk": gsi2pk_product(product_id), ":sk_prefix": "CERTIFICATE#"}, + index_name="GSI3", scan_index_forward=False, ) return [Certificate(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar certificados por product_id {product_id}: {str(e)}") raise @@ -206,25 +212,36 @@ def get_by_product_id(self, product_id: int) -> List[Certificate]: def get_successful_certificates(self) -> List[Certificate]: try: items = self.dynamodb_service.query_table( - self.entity_name, - "success_flag = :success_flag", - {":success_flag": 1}, - index_name="certificates_by_success_idx", + "single", + "GSI4PK = :gsi4pk AND begins_with(GSI4SK, :sk_prefix)", + {":gsi4pk": gsi3pk_success(True), ":sk_prefix": "CERTIFICATE#"}, + index_name="GSI4", scan_index_forward=False, ) return [Certificate(**item) for item in items] - except Exception as e: logger.error(f"Erro ao buscar certificados bem-sucedidos: {str(e)}") raise def _prepare_item(self, entity: Certificate) -> dict: item = entity.model_dump() - item["id"] = str(entity.id) - item["participant_email"] = _normalize_email(item.get("participant_email")) - item["participant_email_product_key"] = _email_product_key( - item.get("participant_email"), - item.get("product_id"), - ) - item["success_flag"] = _success_flag(item.get("success")) - return item + cert_id = str(entity.id) + order_id = entity.order_id + email = _normalize_email(item.get("participant_email")) + success = item.get("success", False) + + item["PK"] = pk(EntityType.CERTIFICATE, order_id) + item["SK"] = sk(EntityType.CERTIFICATE, order_id) + item["EntityType"] = EntityType.CERTIFICATE.value + item["GSI1PK"] = f"CERT#{cert_id}" + item["GSI1SK"] = f"CERTIFICATE#{order_id}" + item["GSI2PK"] = gsi1pk_email(email) if email else None + item["GSI2SK"] = f"CERTIFICATE#{order_id}" + item["GSI3PK"] = gsi2pk_product(entity.product_id) + item["GSI3SK"] = gsi2sk_certificate(order_id) + item["GSI4PK"] = gsi3pk_success(success) + item["GSI4SK"] = gsi3sk_certificate(order_id) + item["id"] = cert_id + item["participant_email"] = email + item["success_flag"] = _success_flag(success) + return {k: v for k, v in item.items() if v is not None} From 5a6057b02798b936308565edf7a5492b7feab011 Mon Sep 17 00:00:00 2001 From: maxsonferovante Date: Sat, 5 Sep 2026 09:39:30 -0300 Subject: [PATCH 2/4] docs: add DynamoDB single-table design documentation --- README.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/README.md b/README.md index 0525319..141bfe1 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,38 @@ Este projeto é uma função AWS Lambda responsável por processar notificaçõe - **Deploy**: o workflow `.github/workflows/workflow_build.yaml` gera um ZIP e atualiza a função `tech-floripa-certificates-notification-dev`. - **Infra**: a fila SQS continua ligada pela infraestrutura Terraform; apenas o artefato de código mudou de imagem para ZIP. +## DynamoDB Single-Table Design + +Este projeto utiliza o padrão DynamoDB Single-Table Design. O CertificateRepository opera na mesma tabela que a API. + +### Estrutura da Tabela (compartilhada) + +| Atributo | Tipo | Descrição | +|----------|------|-----------| +| `PK` | String | Chave de Partição principal | +| `SK` | String | Chave de Ordenação principal | +| `GSI1PK`, `GSI1SK` | String | GSI1 para Certificate por UUID | +| `GSI2PK`, `GSI2SK` | String | GSI2 para acesso por email | +| `GSI3PK`, `GSI3SK` | String | GSI3 para acesso por produto | +| `GSI4PK`, `GSI4SK` | String | GSI4 para certificados por status | +| `GSI5PK`, `GSI5SK` | String | GSI5 para Participants por cidade | +| `EntityType` | String | Tipo da entidade | + +### GSI4 - Acesso por Status de Sucesso + +| GSI4PK | GSI4SK | Uso | +|--------|--------|-----| +| `SUCCESS#true` | `CERTIFICATE#` | Certificados bem-sucedidos | +| `SUCCESS#false` | `CERTIFICATE#` | Certificados falhados | + +### Repositório de Certificados + +O `CertificateRepositoryImpl` utiliza: +- **GSI1**: Busca por UUID do certificado +- **GSI2**: Busca por email do participante +- **GSI3**: Busca por product_id +- **GSI4**: Busca por status de sucesso + ## Estrutura do Evento ### Entrada (SQS) From 25424e82c078a33cdd0798798012e16f4ceeb9bd Mon Sep 17 00:00:00 2001 From: maxsonferovante Date: Mon, 7 Sep 2026 19:10:40 -0300 Subject: [PATCH 3/4] fix: remove gsi4pk_city from dynamodb_keys per PR feedback This notification service only works with certificates, no need for city access pattern. --- src/infrastructure/aws/dynamodb_keys.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/infrastructure/aws/dynamodb_keys.py b/src/infrastructure/aws/dynamodb_keys.py index 4a6cba4..4f77503 100644 --- a/src/infrastructure/aws/dynamodb_keys.py +++ b/src/infrastructure/aws/dynamodb_keys.py @@ -69,14 +69,6 @@ def gsi3sk_certificate(order_id: int) -> str: return f"CERTIFICATE#{order_id}" -def gsi4pk_city(city: str) -> str: - return f"CITY#{city}" - - -def gsi4sk_participant(participant_id: str) -> str: - return f"PARTICIPANT#{participant_id}" - - def gsi1pk_cert_id(cert_id: str) -> str: return f"CERT#{cert_id}" From ef57fa870b244a9dec484741fc8e1148d9ac1f2b Mon Sep 17 00:00:00 2001 From: maxsonferovante Date: Mon, 7 Sep 2026 19:11:45 -0300 Subject: [PATCH 4/4] docs: remove GSI5 from single-table documentation --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 141bfe1..077f111 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,6 @@ Este projeto utiliza o padrão DynamoDB Single-Table Design. O CertificateReposi | `GSI2PK`, `GSI2SK` | String | GSI2 para acesso por email | | `GSI3PK`, `GSI3SK` | String | GSI3 para acesso por produto | | `GSI4PK`, `GSI4SK` | String | GSI4 para certificados por status | -| `GSI5PK`, `GSI5SK` | String | GSI5 para Participants por cidade | | `EntityType` | String | Tipo da entidade | ### GSI4 - Acesso por Status de Sucesso