From 3ab20c485b0aebd916c18549f0edb88bf8c2de19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Thu, 21 Aug 2025 22:18:37 -0300 Subject: [PATCH] add events --- README.md | 6 +- enrollments-events/app/boto3clients.py | 14 +++- .../app/events/allocate_slots.py | 6 +- ...reminder_cert_expiration_before_30_days.py | 57 ++++++++++++- .../emails/reminder_no_access_after_3_days.py | 84 ++++++------------- .../reminder_no_activity_after_7_days.py | 83 ++++++------------ enrollments-events/app/events/enroll.py | 6 +- enrollments-events/pyproject.toml | 1 + enrollments-events/template.yaml | 82 +++++++++++++++++- .../test_reminder_no_access_after_3_days.py | 5 +- enrollments-events/uv.lock | 71 ++++++++++++++++ http-api/app/rules/enrollment.py | 20 +++-- id.saladeaula.digital/client/README.md | 4 +- .../client/app/routes/index.tsx | 10 ++- .../client/app/routes/logo.svg | 7 -- id.saladeaula.digital/client/package.json | 2 +- .../app/events/billing/append_enrollment.py | 10 ++- .../app/events/billing/cancel_enrollment.py | 10 ++- order-events/tests/seeds.jsonl | 1 - 19 files changed, 315 insertions(+), 164 deletions(-) delete mode 100644 id.saladeaula.digital/client/app/routes/logo.svg diff --git a/README.md b/README.md index 5c96a44..bcbc3f6 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,10 @@ Quando uma matrícula é criada, também é agendados emails/eventos. - `REMINDER_NO_ACCESS_AFTER_3_DAYS` se o usuário não acessar o curso 3 dias após a criação. - `REMINDER_NO_ACTIVITY_AFTER_7_DAYS` 7 dias após a última atividade do usuário no curso. -- `REMINDER_ACCESS_PERIOD_BEFORE_15_DAYS` 30 dias antes do perído de acesso ao curso terminar. +- `REMINDER_ACCESS_PERIOD_BEFORE_15_DAYS` 30 dias antes do período de acesso ao curso terminar. - `REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS` se houver certificado, avisa 30 dias antes do certificado expirar. -- `COURSE_ARCHIVED` após o certificado expirar, a matrícula será marcada como **arquivada (ARCHIVED)**. -- `COURSE_EXPIRED` se não houver certificado e o período de acesso for atingido, a matrícula será marcada com **expirada (EXPIRED)**. +- `SET_AS_ARCHIVED` após o certificado expirar, a matrícula será marcada como **arquivada (ARCHIVED)**. +- `SET_AS_EXPIRED` se não houver certificado e o período de acesso for atingido, a matrícula será marcada com **expirada (EXPIRED)**. ```json {"id": "9omWNKymwU5U4aeun6mWzZ", "sk": "SCHEDULES#REMINDER_NO_ACCESS_3_DAYS", "name": "Sérgio R Siqueira", "email": "osergiosiqueira@gmail.com", "ttl": 1874507093} diff --git a/enrollments-events/app/boto3clients.py b/enrollments-events/app/boto3clients.py index 633c1b2..d4c4408 100644 --- a/enrollments-events/app/boto3clients.py +++ b/enrollments-events/app/boto3clients.py @@ -1,14 +1,22 @@ import os +from typing import TYPE_CHECKING import boto3 +if TYPE_CHECKING: + from mypy_boto3_dynamodb.client import DynamoDBClient + from mypy_boto3_sesv2 import SESV2Client +else: + DynamoDBClient = object + SESV2Client = object -def get_dynamodb_client(): + +def get_dynamodb_client() -> DynamoDBClient: if os.getenv('AWS_LAMBDA_FUNCTION_NAME'): return boto3.client('dynamodb') return boto3.client('dynamodb', endpoint_url='http://127.0.0.1:8000') -dynamodb_client = get_dynamodb_client() -sesv2_client = boto3.client('sesv2') +dynamodb_client: DynamoDBClient = get_dynamodb_client() +sesv2_client: SESV2Client = boto3.client('sesv2') diff --git a/enrollments-events/app/events/allocate_slots.py b/enrollments-events/app/events/allocate_slots.py index 47b1a01..253068a 100644 --- a/enrollments-events/app/events/allocate_slots.py +++ b/enrollments-events/app/events/allocate_slots.py @@ -34,7 +34,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: order = order_layer.collection.get_items( TransactKey(order_id) + SortKey('0') + SortKey('items', path_spec='items'), ) - tenant_id = order['tenant_id'] + org_id = order['tenant_id'] items = { item['id']: int(item['quantity']) for item in order['items'] @@ -51,10 +51,10 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: for slot in slots: transact.put( item={ - 'id': f'vacancies#{tenant_id}', + 'id': f'vacancies#{org_id}', 'sk': f'{order_id}#{uuid4()}', # Post-migration: uncomment the follow lines - # 'id': f'SLOT#ORG#{tenant_id}', + # 'id': f'SLOT#ORG#{org_id}', # 'sk': f'ORDER#{order_id}#ENROLLMENT#{uuid4()}', 'course': asdict(slot), 'created_at': now_, diff --git a/enrollments-events/app/events/emails/reminder_cert_expiration_before_30_days.py b/enrollments-events/app/events/emails/reminder_cert_expiration_before_30_days.py index 78941b7..18ca36f 100644 --- a/enrollments-events/app/events/emails/reminder_cert_expiration_before_30_days.py +++ b/enrollments-events/app/events/emails/reminder_cert_expiration_before_30_days.py @@ -1,12 +1,61 @@ from aws_lambda_powertools import Logger -from aws_lambda_powertools.utilities.data_classes import EventBridgeEvent, event_source +from aws_lambda_powertools.utilities.data_classes import ( + EventBridgeEvent, + event_source, +) from aws_lambda_powertools.utilities.typing import LambdaContext +from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair + +from boto3clients import dynamodb_client, sesv2_client +from config import ( + EMAIL_SENDER, + ENROLLMENT_TABLE, +) + +from .email_ import send_email logger = Logger(__name__) +enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) -@event_source(data_classe=EventBridgeEvent) +SUBJECT = 'Seu certificado de {course} está prestes a expirar' +MESSAGE = """ +Oi {first_name}, tudo bem?

+ +O certificado do curso {course} vai expirar em breve.
+Para manter sua certificação válida, é recomendável refazer o curso 30 dias antes da expiração.

+ +👉 Acesse o curso e renove sua certificação +""" + + +@event_source(data_class=EventBridgeEvent) @logger.inject_lambda_context def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: - new_image = event.detail['new_image'] - return True + """If a certificate exists, remind the user 30 days before + the certificate expires.""" + old_image = event.detail['old_image'] + + # Post-migration: Remove the following lines + if 'email' not in old_image: + # If email is missing, use enrollment email + cur_image = enrollment_layer.get_item(KeyPair(old_image['id'], '0')) + old_image['name'] = cur_image['user']['name'] + old_image['email'] = cur_image['user']['email'] + old_image['course'] = cur_image['course']['name'] + + return send_email( + to=(old_image['name'], old_image['email']), + subject=SUBJECT, + message=MESSAGE, + context={ + 'course': old_image['course'], + }, + sender=EMAIL_SENDER, + sesv2_client=sesv2_client, + event={ + 'id': old_image['id'], + 'sk': 'SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS', + }, + dynamodb_persistence_layer=enrollment_layer, + ) diff --git a/enrollments-events/app/events/emails/reminder_no_access_after_3_days.py b/enrollments-events/app/events/emails/reminder_no_access_after_3_days.py index 1661b91..a620b6b 100644 --- a/enrollments-events/app/events/emails/reminder_no_access_after_3_days.py +++ b/enrollments-events/app/events/emails/reminder_no_access_after_3_days.py @@ -4,10 +4,7 @@ from aws_lambda_powertools.utilities.data_classes import ( event_source, ) from aws_lambda_powertools.utilities.typing import LambdaContext -from layercake.dateutils import now from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair -from layercake.email_ import Message -from layercake.strutils import first_word, truncate_str from boto3clients import dynamodb_client, sesv2_client from config import ( @@ -15,26 +12,28 @@ from config import ( ENROLLMENT_TABLE, ) +from .email_ import send_email + +logger = Logger(__name__) +enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) + SUBJECT = 'Seu curso de {course} está esperando por você na EDUSEG®' MESSAGE = """ Oi {first_name}, tudo bem?

-Você foi matriculado no curso de {course} há 3 dias, mas ainda não iniciou.
-Não perca a oportunidade de aprender e aproveitar ao máximo seu curso!

+Há 3 dias você foi matriculado no curso {course}.
+Ainda não começou? Não perca a oportunidade de aprender e aproveitar ao máximo seu curso!

-Clique no link para acessar seu curso: -https://saladeaula.digital +👉 Acesse seu curso agora """ -logger = Logger(__name__) -enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) - @event_source(data_class=EventBridgeEvent) @logger.inject_lambda_context def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: + """If the user does not access the course within 3 days after + enrollment creation.""" old_image = event.detail['old_image'] - now_ = now() # Post-migration: Remove the following lines if 'email' not in old_image: @@ -44,53 +43,18 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: old_image['email'] = cur_image['user']['email'] old_image['course'] = cur_image['course']['name'] - emailmsg = Message( - from_=EMAIL_SENDER, - to=( - old_image['name'], - old_image['email'], - ), - subject=SUBJECT.format(course=truncate_str(old_image['course'])), + return send_email( + to=(old_image['name'], old_image['email']), + subject=SUBJECT, + message=MESSAGE, + context={ + 'course': old_image['course'], + }, + sender=EMAIL_SENDER, + sesv2_client=sesv2_client, + event={ + 'id': old_image['id'], + 'sk': 'SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS', + }, + dynamodb_persistence_layer=enrollment_layer, ) - emailmsg.add_alternative( - MESSAGE.format( - first_name=first_word(old_image['name']), - course=old_image['course'], - ) - ) - - try: - sesv2_client.send_email( - Content={ - 'Raw': { - 'Data': emailmsg.as_bytes(), - }, - } - ) - logger.info('Email sent') - except Exception as exc: - logger.exception(exc) - - enrollment_layer.put_item( - item={ - 'id': old_image['id'], - 'sk': 'SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS#FAILED', - # Post-migration: Uncomment the following line - # 'sk': f'{old_image["sk"]}#FAILED', - 'created_at': now_, - } - ) - - return False - else: - enrollment_layer.put_item( - item={ - 'id': old_image['id'], - 'sk': 'SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS#EXECUTED', - # Post-migration: Uncomment the following line - # 'sk': f'{old_image["sk"]}#EXECUTED', - 'created_at': now_, - } - ) - - return True diff --git a/enrollments-events/app/events/emails/reminder_no_activity_after_7_days.py b/enrollments-events/app/events/emails/reminder_no_activity_after_7_days.py index 1dbb9f7..a8c81b1 100644 --- a/enrollments-events/app/events/emails/reminder_no_activity_after_7_days.py +++ b/enrollments-events/app/events/emails/reminder_no_activity_after_7_days.py @@ -4,10 +4,7 @@ from aws_lambda_powertools.utilities.data_classes import ( event_source, ) from aws_lambda_powertools.utilities.typing import LambdaContext -from layercake.dateutils import now from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair -from layercake.email_ import Message -from layercake.strutils import first_word, truncate_str from boto3clients import dynamodb_client, sesv2_client from config import ( @@ -15,19 +12,28 @@ from config import ( ENROLLMENT_TABLE, ) -SUBJECT = '' -MESSAGE = """ -""" +from .email_ import send_email logger = Logger(__name__) enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) +SUBJECT = 'Seu curso de {course} está parado há 7 dias...' +MESSAGE = """ +Oi {first_name}, tudo bem?

+ +Percebemos que você não acessou seu curso {course} nos últimos 7 dias.
+Não deixe seu período de acesso expirar! Retome seu aprendizado agora mesmo.

+ +👉 Clique aqui para acessar seu curso +""" + + @event_source(data_class=EventBridgeEvent) @logger.inject_lambda_context def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: + """7 days after the user's last activity in the course.""" old_image = event.detail['old_image'] - now_ = now() # Post-migration: Remove the following lines if 'email' not in old_image: @@ -37,53 +43,18 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: old_image['email'] = cur_image['user']['email'] old_image['course'] = cur_image['course']['name'] - emailmsg = Message( - from_=EMAIL_SENDER, - to=( - old_image['name'], - old_image['email'], - ), - subject=SUBJECT.format(course=truncate_str(old_image['course'])), + return send_email( + to=(old_image['name'], old_image['email']), + subject=SUBJECT, + message=MESSAGE, + context={ + 'course': old_image['course'], + }, + sender=EMAIL_SENDER, + sesv2_client=sesv2_client, + event={ + 'id': old_image['id'], + 'sk': 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS', + }, + dynamodb_persistence_layer=enrollment_layer, ) - emailmsg.add_alternative( - MESSAGE.format( - first_name=first_word(old_image['name']), - course=old_image['course'], - ) - ) - - try: - sesv2_client.send_email( - Content={ - 'Raw': { - 'Data': emailmsg.as_bytes(), - }, - } - ) - logger.info('Email sent') - except Exception as exc: - logger.exception(exc) - - enrollment_layer.put_item( - item={ - 'id': old_image['id'], - 'sk': 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS#FAILED', - # Post-migration: Uncomment the following line - # 'sk': f'{old_image["sk"]}#FAILED', - 'created_at': now_, - } - ) - - return False - else: - enrollment_layer.put_item( - item={ - 'id': old_image['id'], - 'sk': 'SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS#EXECUTED', - # Post-migration: Uncomment the following line - # 'sk': f'{old_image["sk"]}#EXECUTED', - 'created_at': now_, - } - ) - - return True diff --git a/enrollments-events/app/events/enroll.py b/enrollments-events/app/events/enroll.py index 718df0e..6122f8e 100644 --- a/enrollments-events/app/events/enroll.py +++ b/enrollments-events/app/events/enroll.py @@ -89,7 +89,11 @@ def _handler(record: Course, context: dict) -> Enrollment: enrollment, persistence_layer=enrollment_layer, deduplication_window=DeduplicationWindow(offset_days=90), - linked_entities=frozenset({LinkedEntity(context['order_id'], 'ORDER')}), + linked_entities=frozenset( + { + LinkedEntity(context['order_id'], 'ORDER'), + } + ), ) return enrollment diff --git a/enrollments-events/pyproject.toml b/enrollments-events/pyproject.toml index 4265714..06b461a 100644 --- a/enrollments-events/pyproject.toml +++ b/enrollments-events/pyproject.toml @@ -8,6 +8,7 @@ dependencies = ["layercake"] [dependency-groups] dev = [ + "boto3-stubs[dynamodb,sesv2]>=1.40.15", "jsonlines>=4.0.0", "pytest>=8.3.4", "pytest-cov>=6.0.0", diff --git a/enrollments-events/template.yaml b/enrollments-events/template.yaml index be81735..e8ca516 100644 --- a/enrollments-events/template.yaml +++ b/enrollments-events/template.yaml @@ -140,6 +140,32 @@ Resources: scope: [SINGLE_USER] status: [PENDING] + EventReenrollIfFailedFunction: + Type: AWS::Serverless::Function + Properties: + Handler: events.reenroll_if_failed.lambda_handler + LoggingConfig: + LogGroup: !Ref EventLog + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref EnrollmentTable + Events: + DynamoDBEvent: + Type: EventBridgeRule + Properties: + Pattern: + resources: [!Ref EnrollmentTable] + detail-type: [MODIFY] + detail: + changes: [status] + new_image: + sk: ["0"] + status: [FAILED] + score: + - numeric: ["<", 70] + old_image: + status: [IN_PROGRESS] + EventAllocateSlotsFunction: Type: AWS::Serverless::Function Properties: @@ -182,6 +208,7 @@ Resources: - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/eduseg.com.br - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:configuration-set/tracking + # If the user does not access the course within 3 days after enrollment creation EventReminderNoAccessAfter3DaysFunction: Type: AWS::Serverless::Function Properties: @@ -204,10 +231,11 @@ Resources: sk: - SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS # Post-migration: remove the following lines - - SCHEDULES#REMINDER_NO_ACCESS_AFTER_3_DAYS - schedules#does_not_access - schedules#reminder_no_access_3_days + - SCHEDULES#REMINDER_NO_ACCESS_AFTER_3_DAYS + # 7 days after the user's last activity in the course EventReminderNoActivityAfter7DaysFunction: Type: AWS::Serverless::Function Properties: @@ -229,6 +257,58 @@ Resources: keys: sk: - SCHEDULE#REMINDER_NO_ACTIVITY_AFTER_7_DAYS + # Post-migration: remove the following line + - schedules#no_activity + + # 30 days before the course access period ends. + EventReminderAccessPeriodBefore30DaysFunction: + Type: AWS::Serverless::Function + Properties: + Handler: events.emails.reminder_access_period_before_30_days.lambda_handler + LoggingConfig: + LogGroup: !Ref EventLog + Policies: + - !Ref SesPolicy + - DynamoDBCrudPolicy: + TableName: !Ref EnrollmentTable + Events: + DynamoDBEvent: + Type: EventBridgeRule + Properties: + Pattern: + resources: [!Ref EnrollmentTable] + detail-type: [EXPIRE] + detail: + keys: + sk: + - SCHEDULE#REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS + # Post-migration: remove the following line + - schedules#access_period_ends + + # If a certificate exists, remind the user 30 days before the certificate expires + EventReminderCertExpirationBefore30DaysFunction: + Type: AWS::Serverless::Function + Properties: + Handler: events.emails.reminder_cert_expiration_before_30_days.lambda_handler + LoggingConfig: + LogGroup: !Ref EventLog + Policies: + - !Ref SesPolicy + - DynamoDBCrudPolicy: + TableName: !Ref EnrollmentTable + Events: + DynamoDBEvent: + Type: EventBridgeRule + Properties: + Pattern: + resources: [!Ref EnrollmentTable] + detail-type: [EXPIRE] + detail: + keys: + sk: + - SCHEDULE#REMINDER_CERT_EXPIRATION_BEFORE_30_DAYS + # Post-migration: remove the following line + - schedules#expiration EventScheduleRemindersFunction: Type: AWS::Serverless::Function diff --git a/enrollments-events/tests/events/emails/test_reminder_no_access_after_3_days.py b/enrollments-events/tests/events/emails/test_reminder_no_access_after_3_days.py index dcf7674..64a6188 100644 --- a/enrollments-events/tests/events/emails/test_reminder_no_access_after_3_days.py +++ b/enrollments-events/tests/events/emails/test_reminder_no_access_after_3_days.py @@ -3,7 +3,6 @@ from aws_lambda_powertools.utilities.typing import LambdaContext def test_reminder_no_access_after_3_days( - dynamodb_client, dynamodb_seeds, lambda_context: LambdaContext, ): @@ -11,9 +10,9 @@ def test_reminder_no_access_after_3_days( 'detail': { 'old_image': { 'id': '47ZxxcVBjvhDS5TE98tpfQ', - 'sk': 'schedules#reminder_no_access_3_days', + 'sk': 'SCHEDULE#REMINDER_NO_ACCESS_AFTER_3_DAYS', } } } - assert app.lambda_handler(event, lambda_context) + assert app.lambda_handler(event, lambda_context) # type: ignore diff --git a/enrollments-events/uv.lock b/enrollments-events/uv.lock index 9703cc7..18f495c 100644 --- a/enrollments-events/uv.lock +++ b/enrollments-events/uv.lock @@ -115,6 +115,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e8/d9/d676f22160055bf29b28ace2e0e6853c10c338c1fbaaf3d6234f85c2857c/boto3-1.38.20-py3-none-any.whl", hash = "sha256:0494bafa771561c02ae5926143ce69b6ee4017f11ced22d0293a8372acb7472a", size = 139936, upload-time = "2025-05-20T23:12:56.529Z" }, ] +[[package]] +name = "boto3-stubs" +version = "1.40.15" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "botocore-stubs" }, + { name = "types-s3transfer" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/54/c8a0d43c5d17e20433b23ee78cc8348b0cba5a5255d6c2f66aafa86c64ad/boto3_stubs-1.40.15.tar.gz", hash = "sha256:47370ffdfd9f1899900bba554f4ae1846423c459beaccf11e2eae46896af5119", size = 101393, upload-time = "2025-08-21T19:48:27.26Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/40/fe3cb27e3eee35815902a37d26b7c0af308fe2b08cd0671fb0a8475dfa12/boto3_stubs-1.40.15-py3-none-any.whl", hash = "sha256:95b6a828b758ed56d90ea2530a6794506ca403cfbef3bd2584a2e7c43e3f6607", size = 70011, upload-time = "2025-08-21T19:48:20.458Z" }, +] + +[package.optional-dependencies] +dynamodb = [ + { name = "mypy-boto3-dynamodb" }, +] +sesv2 = [ + { name = "mypy-boto3-sesv2" }, +] + [[package]] name = "botocore" version = "1.38.20" @@ -129,6 +150,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e2/be/f0eb1d687ca841f9a8cf6077340123ade5069984121b67e0709b3a368851/botocore-1.38.20-py3-none-any.whl", hash = "sha256:70feba9b3f73946a9739d0c16703190d79379f065cf6e29883b5d7f791b247b8", size = 13558776, upload-time = "2025-05-20T23:12:39.685Z" }, ] +[[package]] +name = "botocore-stubs" +version = "1.38.46" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-awscrt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/05/45/27cabc7c3022dcb12de5098cc646b374065f5e72fae13600ff1756f365ee/botocore_stubs-1.38.46.tar.gz", hash = "sha256:a04e69766ab8bae338911c1897492f88d05cd489cd75f06e6eb4f135f9da8c7b", size = 42299, upload-time = "2025-06-29T22:58:24.765Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/84/06490071e26bab22ac79a684e98445df118adcf80c58c33ba5af184030f2/botocore_stubs-1.38.46-py3-none-any.whl", hash = "sha256:cc21d9a7dd994bdd90872db4664d817c4719b51cda8004fd507a4bf65b085a75", size = 66083, upload-time = "2025-06-29T22:58:22.234Z" }, +] + [[package]] name = "camel-converter" version = "4.0.1" @@ -334,6 +367,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "boto3-stubs", extra = ["dynamodb", "sesv2"] }, { name = "jsonlines" }, { name = "pytest" }, { name = "pytest-cov" }, @@ -345,6 +379,7 @@ requires-dist = [{ name = "layercake", directory = "../layercake" }] [package.metadata.requires-dev] dev = [ + { name = "boto3-stubs", extras = ["dynamodb", "sesv2"], specifier = ">=1.40.15" }, { name = "jsonlines", specifier = ">=4.0.0" }, { name = "pytest", specifier = ">=8.3.4" }, { name = "pytest-cov", specifier = ">=6.0.0" }, @@ -520,6 +555,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bf/3e/a3ec8d44b35e495444cac8ce3573b33adf19a9b6d70f2a51e4a971f17c81/meilisearch-0.34.1-py3-none-any.whl", hash = "sha256:43efa4521ce7dc3b065d404267ad5b3acb825602e6219b8b5356650306686cd4", size = 24918, upload-time = "2025-04-04T13:45:06.869Z" }, ] +[[package]] +name = "mypy-boto3-dynamodb" +version = "1.40.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/85/f5d4261c084cac14e4f19bb074f9292f68e18493174289fb21e07339f25c/mypy_boto3_dynamodb-1.40.14.tar.gz", hash = "sha256:7ec8eb714ac080e7d5572ec8c556953930aba5d2fbcc058aa3cbb87ccce4ac79", size = 47978, upload-time = "2025-08-20T19:27:30.166Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/b3/6f2e15a44e66a8cc98fd1032f3aba770f946ba361782a0a979a115fdf6e2/mypy_boto3_dynamodb-1.40.14-py3-none-any.whl", hash = "sha256:302cc169dde3b87a41924855dcfbae173247e18833dee80919f7cc690189f376", size = 57017, upload-time = "2025-08-20T19:27:20.701Z" }, +] + +[[package]] +name = "mypy-boto3-sesv2" +version = "1.40.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a7/df/8f1ccaacb68e65b363945627595eced58c8adeeefe782e4a5ab2b9b507af/mypy_boto3_sesv2-1.40.0.tar.gz", hash = "sha256:6862b8e3e7d32b04fee68e1b72acf51af3a6ffd8293f7a17f3612d6bfb9773cc", size = 46597, upload-time = "2025-07-31T19:51:20.069Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/c9/bc8df41c06ec3cc6cc22b653859ebae070d2f54f30a2e9c9632e2d8273b4/mypy_boto3_sesv2-1.40.0-py3-none-any.whl", hash = "sha256:493569840df0a55ba8c616635a1154f60f9802fe793862fef00b0231bb27768e", size = 51741, upload-time = "2025-07-31T19:51:18.213Z" }, +] + [[package]] name = "orjson" version = "3.10.18" @@ -852,6 +905,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252, upload-time = "2022-10-06T17:21:44.262Z" }, ] +[[package]] +name = "types-awscrt" +version = "0.27.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/56/ce/5d84526a39f44c420ce61b16654193f8437d74b54f21597ea2ac65d89954/types_awscrt-0.27.6.tar.gz", hash = "sha256:9d3f1865a93b8b2c32f137514ac88cb048b5bc438739945ba19d972698995bfb", size = 16937, upload-time = "2025-08-13T01:54:54.659Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/af/e3d20e3e81d235b3964846adf46a334645a8a9b25a0d3d472743eb079552/types_awscrt-0.27.6-py3-none-any.whl", hash = "sha256:18aced46da00a57f02eb97637a32e5894dc5aa3dc6a905ba3e5ed85b9f3c526b", size = 39626, upload-time = "2025-08-13T01:54:53.454Z" }, +] + +[[package]] +name = "types-s3transfer" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/c1/45038f259d6741c252801044e184fec4dbaeff939a58f6160d7c32bf4975/types_s3transfer-0.13.0.tar.gz", hash = "sha256:203dadcb9865c2f68fb44bc0440e1dc05b79197ba4a641c0976c26c9af75ef52", size = 14175, upload-time = "2025-05-28T02:16:07.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5d/6bbe4bf6a79fb727945291aef88b5ecbdba857a603f1bbcf1a6be0d3f442/types_s3transfer-0.13.0-py3-none-any.whl", hash = "sha256:79c8375cbf48a64bff7654c02df1ec4b20d74f8c5672fc13e382f593ca5565b3", size = 19588, upload-time = "2025-05-28T02:16:06.709Z" }, +] + [[package]] name = "typing-extensions" version = "4.13.2" diff --git a/http-api/app/rules/enrollment.py b/http-api/app/rules/enrollment.py index 9c1b2c4..5b56eb0 100644 --- a/http-api/app/rules/enrollment.py +++ b/http-api/app/rules/enrollment.py @@ -65,6 +65,16 @@ class LifecycleEvents(str, Enum): EXPIRATION = 'schedules#expiration' +class SlotDoesNotExistError(Exception): + def __init__(self, *args): + super().__init__('Slot does not exist') + + +class DeduplicationConflictError(Exception): + def __init__(self, *args): + super().__init__('Enrollment already exists') + + def enroll( enrollment: Enrollment, *, @@ -151,7 +161,7 @@ def enroll( transact.put( item={ 'id': enrollment.id, - 'sk': f'linked_entities#{type}', + 'sk': f'LINKED_ENTITIES#{type}', 'created_at': now_, f'{type}_id': entity.id, } @@ -169,10 +179,6 @@ def enroll( } ) - class SlotDoesNotExistError(Exception): - def __init__(self, *args): - super().__init__('Slot does not exist') - transact.delete( key=KeyPair(slot.id, slot.sk), cond_expr='attribute_exists(sk)', @@ -197,10 +203,6 @@ def enroll( }, ) - class DeduplicationConflictError(Exception): - def __init__(self, *args): - super().__init__('Enrollment already exists') - # Prevents the user from enrolling in the same course again until # the deduplication window expires or is removed. if deduplication_window: diff --git a/id.saladeaula.digital/client/README.md b/id.saladeaula.digital/client/README.md index a48c3e1..87188eb 100644 --- a/id.saladeaula.digital/client/README.md +++ b/id.saladeaula.digital/client/README.md @@ -1 +1,3 @@ -# id.saladeaula.digital +# [id.saladeaula.digital](https://id.saladeaula.digital) + +O código-fonte para [id.saladeaula.digital](https://id.saladeaula.digital), construído com [React Router](https://github.com/remix-run/react-router). diff --git a/id.saladeaula.digital/client/app/routes/index.tsx b/id.saladeaula.digital/client/app/routes/index.tsx index a8ec339..19d0cbb 100644 --- a/id.saladeaula.digital/client/app/routes/index.tsx +++ b/id.saladeaula.digital/client/app/routes/index.tsx @@ -3,6 +3,7 @@ import type { Route } from './+types' import { isValidCPF } from '@brazilian-utils/brazilian-utils' import { zodResolver } from '@hookform/resolvers/zod' import { Loader2Icon } from 'lucide-react' +import { useState } from 'react' import { useForm } from 'react-hook-form' import { useFetcher } from 'react-router' import { z } from 'zod' @@ -13,8 +14,7 @@ import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import * as httpStatus from '@/lib/http-status' -import { useState } from 'react' -import logo from './logo.svg' +import logo from '@/components/logo.svg' const cpf = z.string().refine(isValidCPF, { message: 'CPF inválido' }) const email = z.string().email({ message: 'Email inválido' }) @@ -142,7 +142,11 @@ export default function Index({}: Route.ComponentProps) {

Ao fazer login, você concorda com nossa{' '} - + política de privacidade . diff --git a/id.saladeaula.digital/client/app/routes/logo.svg b/id.saladeaula.digital/client/app/routes/logo.svg deleted file mode 100644 index bbe178c..0000000 --- a/id.saladeaula.digital/client/app/routes/logo.svg +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/id.saladeaula.digital/client/package.json b/id.saladeaula.digital/client/package.json index 00a8420..53723ea 100644 --- a/id.saladeaula.digital/client/package.json +++ b/id.saladeaula.digital/client/package.json @@ -1,5 +1,5 @@ { - "name": "client", + "name": "id-saladeaula-digital", "private": true, "type": "module", "scripts": { diff --git a/order-events/app/events/billing/append_enrollment.py b/order-events/app/events/billing/append_enrollment.py index 048b730..81fa628 100644 --- a/order-events/app/events/billing/append_enrollment.py +++ b/order-events/app/events/billing/append_enrollment.py @@ -44,6 +44,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: org_id = new_image['org_id'] enrollment = enrollment_layer.collection.get_items( TransactKey(new_image['id']) + SortKey('0') + SortKey('author') + # Post-migration: uncomment the following line + # + SortKey('CREATED_BY') ) if not enrollment: @@ -97,7 +99,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: # Add enrollment entry to billing try: - author = enrollment.get('author') + canceled_by = enrollment.get('author') course_id = enrollment['course']['id'] course = course_layer.collection.get_items( KeyPair( @@ -129,11 +131,11 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: | ( { 'author': { - 'id': author['user_id'], - 'name': author['name'], + 'id': canceled_by['user_id'], + 'name': canceled_by['name'], } } - if author + if canceled_by else {} ), cond_expr='attribute_not_exists(sk)', diff --git a/order-events/app/events/billing/cancel_enrollment.py b/order-events/app/events/billing/cancel_enrollment.py index f1f78d8..0229cbd 100644 --- a/order-events/app/events/billing/cancel_enrollment.py +++ b/order-events/app/events/billing/cancel_enrollment.py @@ -29,8 +29,8 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: TransactKey(enrollment_id) + SortKey('METADATA#SUBSCRIPTION_COVERED') # Post-migration: uncomment the following line - # + SortKey('CANCELED', path_spec='author', rename_key='author') - + SortKey('canceled', path_spec='author', rename_key='author') + # + SortKey('CANCELED', path_spec='canceled_by', rename_key='canceled_by') + + SortKey('canceled', path_spec='author', rename_key='canceled_by') ) created_at: datetime = fromisoformat(new_image['create_date']) # type: ignore @@ -49,7 +49,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: return False try: - author = subscription.get('author') + canceled_by = subscription.get('canceled_by') # Retrieve canceled enrollment data old_enrollment = order_layer.collection.get_item( KeyPair( @@ -68,7 +68,9 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool: } | pick(('user', 'course', 'enrolled_at'), old_enrollment) # Add author if present - | ({'author': author} if author else {}), + | ({'author': canceled_by} if canceled_by else {}), + # Post-migration: uncomment the following line + # | ({'created_by': canceled_by} if canceled_by else {}), cond_expr='attribute_not_exists(sk)', ) except Exception as exc: diff --git a/order-events/tests/seeds.jsonl b/order-events/tests/seeds.jsonl index 15519eb..f76a89e 100644 --- a/order-events/tests/seeds.jsonl +++ b/order-events/tests/seeds.jsonl @@ -30,7 +30,6 @@ {"id": "77055ad7-03e1-4b07-98dc-a2f1a90913ba", "sk": "METADATA#SUBSCRIPTION_COVERED", "billing_day": 6, "org_id": "cJtK9SsnJhKPyxESe7g3DG"} {"id": "77055ad7-03e1-4b07-98dc-a2f1a90913ba", "sk": "canceled", "canceled_at": "2025-08-18T15:41:49.927856-03:00", "author": {"id": "123", "name": "Dexter Holland"}} - // Course {"id": "123", "sk": "0", "access_period": "360", "cert": {"exp_interval": 360}, "created_at": "2024-12-30T00:33:33.088916-03:00", "metadata__konviva_class_id": "194", "metadata__unit_price": 99, "name": "Direção Defensiva (08 horas)", "tenant_id": "*", "updated_at": "2025-07-24T00:00:24.639003-03:00"} {"id": "CUSTOM_PRICING#ORG#cJtK9SsnJhKPyxESe7g3DG", "sk": "COURSE#123", "created_at": "2025-07-24T16:10:09.304073-03:00", "unit_price": "79.2"}