diff --git a/enrollments-events/app/boto3clients.py b/enrollments-events/app/boto3clients.py
index 05de43d..633c1b2 100644
--- a/enrollments-events/app/boto3clients.py
+++ b/enrollments-events/app/boto3clients.py
@@ -11,3 +11,4 @@ def get_dynamodb_client():
dynamodb_client = get_dynamodb_client()
+sesv2_client = boto3.client('sesv2')
diff --git a/enrollments-events/app/config.py b/enrollments-events/app/config.py
index bdf9d6d..1aada9e 100644
--- a/enrollments-events/app/config.py
+++ b/enrollments-events/app/config.py
@@ -5,8 +5,9 @@ ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore
ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_TABLE') # type: ignore
COURSE_TABLE: str = os.getenv('COURSE_TABLE') # type: ignore
+EMAIL_SENDER = ('EDUSEG', 'noreply@eduseg.com.br')
-# Post-migration: remove the lines below
+# Post-migration: Remove the following lines
if os.getenv('AWS_LAMBDA_FUNCTION_NAME'):
SQLITE_DATABASE = 'courses_export_2025-06-18_110214.db'
else:
diff --git a/enrollments-events/app/email_.py b/enrollments-events/app/email_.py
new file mode 100644
index 0000000..a625c98
--- /dev/null
+++ b/enrollments-events/app/email_.py
@@ -0,0 +1,64 @@
+from email.mime.application import MIMEApplication
+from email.mime.multipart import MIMEMultipart
+from email.mime.text import MIMEText
+from email.utils import formataddr
+from pathlib import Path
+
+
+class Message:
+ def __init__(
+ self,
+ from_: tuple[str | None, str],
+ to: tuple[str | None, str],
+ subject: str,
+ reply_to: tuple[str | None, str] | None = None,
+ content: str | None = None,
+ ) -> None:
+ self._references = set()
+ self._body = MIMEMultipart('alternative')
+ self._message = MIMEMultipart('mixed')
+ self._message['From'] = formataddr(from_)
+ self._message['To'] = formataddr(to)
+ self._message['Subject'] = subject
+ self._message.attach(self._body)
+
+ if reply_to:
+ self._message['Reply-To'] = formataddr(reply_to)
+
+ if content:
+ self.add_alternative(content, subtype='plain')
+
+ def add_header(self, name: str, value: str) -> None:
+ self._message.add_header(name, value)
+
+ def add_in_reply_to(self, message_id: str) -> None:
+ self._message['In-Reply-To'] = message_id
+ self._references.add(message_id) # Add to set avoids duplicates
+
+ def add_alternative(
+ self,
+ text: str,
+ /,
+ subtype: str = 'html',
+ charset: str = 'utf-8',
+ ) -> None:
+ self._body.attach(MIMEText(text, subtype, charset))
+
+ def attach(self, path: Path, filename: str | None = None) -> None:
+ if not path.is_file():
+ return None
+
+ with path.open('rb') as fp:
+ part = MIMEApplication(fp.read())
+ part.add_header(
+ 'Content-Disposition',
+ 'attachment',
+ filename=filename or path.name,
+ )
+ self._message.attach(part)
+
+ def as_bytes(self) -> bytes:
+ if self._references:
+ self._message['References'] = ' '.join(self._references)
+
+ return self._message.as_bytes()
diff --git a/enrollments-events/app/events/emails/__init__.py b/enrollments-events/app/events/emails/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/enrollments-events/app/events/emails/reminder_no_access_3_days.py b/enrollments-events/app/events/emails/reminder_no_access_3_days.py
new file mode 100644
index 0000000..0628e22
--- /dev/null
+++ b/enrollments-events/app/events/emails/reminder_no_access_3_days.py
@@ -0,0 +1,94 @@
+from aws_lambda_powertools import Logger
+from aws_lambda_powertools.utilities.data_classes import (
+ EventBridgeEvent,
+ event_source,
+)
+from aws_lambda_powertools.utilities.typing import LambdaContext
+from layercake.dateutils import now
+from layercake.dynamodb import ComposeKey, DynamoDBPersistenceLayer, KeyPair
+from layercake.strutils import first_word, truncate_str
+
+from boto3clients import dynamodb_client, sesv2_client
+from config import (
+ EMAIL_SENDER,
+ ENROLLMENT_TABLE,
+)
+from email_ import Message
+
+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?
+
+Há 3 dias você foi matriculado no curso de {course}, mas ainda não iniciou.
+Não perca a oportunidade de aprender e aproveitar ao máximo seu curso!
+
+Clique no link abaixo para acessar seu curso:
+https://saladeaula.digital
+"""
+
+
+@event_source(data_class=EventBridgeEvent)
+@logger.inject_lambda_context
+def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
+ old_image = event.detail['old_image']
+ now_ = now()
+
+ # Post-migration: Remove the following lines
+ if 'email' not in old_image:
+ data = enrollment_layer.get_item(KeyPair(old_image['id'], '0'))
+ old_image['name'] = data['user']['name']
+ old_image['email'] = data['user']['email']
+ old_image['course'] = data['course']['name']
+
+ emailmsg = Message(
+ from_=EMAIL_SENDER,
+ to=(
+ old_image['name'],
+ old_image['email'],
+ ),
+ subject=SUBJECT.format(course=truncate_str(old_image['course'])),
+ )
+ 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(),
+ },
+ }
+ )
+ except Exception as exc:
+ logger.exception(exc)
+ enrollment_layer.put_item(
+ item={
+ 'id': old_image['id'],
+ 'sk': ComposeKey('failed', 'schedules#reminder_no_access_3_days'),
+ # Post-migration: Uncomment the following line
+ # 'sk': ComposeKey('failed', old_image['sk']),
+ 'created_at': now_,
+ }
+ )
+
+ return False
+ else:
+ enrollment_layer.put_item(
+ item={
+ 'id': old_image['id'],
+ 'sk': ComposeKey('completed', 'schedules#reminder_no_access_3_days'),
+ # Post-migration: Uncomment the following line
+ # 'sk': ComposeKey('completed', old_image['sk']),
+ 'created_at': now_,
+ }
+ )
+
+ return True
diff --git a/enrollments-events/app/events/issue_cert.py b/enrollments-events/app/events/issue_cert.py
index 0ad0a73..e48c7d3 100644
--- a/enrollments-events/app/events/issue_cert.py
+++ b/enrollments-events/app/events/issue_cert.py
@@ -8,13 +8,11 @@ from layercake.dynamodb import DynamoDBPersistenceLayer
from boto3clients import dynamodb_client
from config import (
- COURSE_TABLE,
ENROLLMENT_TABLE,
)
logger = Logger(__name__)
enrollment_layer = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
-course_layer = DynamoDBPersistenceLayer(COURSE_TABLE, dynamodb_client)
@event_source(data_class=EventBridgeEvent)
diff --git a/enrollments-events/template.yaml b/enrollments-events/template.yaml
index c84dee1..1393786 100644
--- a/enrollments-events/template.yaml
+++ b/enrollments-events/template.yaml
@@ -62,6 +62,36 @@ Resources:
new_image:
sk: ["0"]
+ EventReminderNoAccess3DaysFunction:
+ Type: AWS::Serverless::Function
+ Properties:
+ Handler: events.emails.reminder_no_access_3_days.lambda_handler
+ LoggingConfig:
+ LogGroup: !Ref EventLog
+ Policies:
+ - DynamoDBCrudPolicy:
+ TableName: !Ref EnrollmentTable
+ - Version: 2012-10-17
+ Statement:
+ - Effect: Allow
+ Action:
+ - ses:SendRawEmail
+ Resource:
+ - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:identity/eduseg.com.br
+ - !Sub arn:aws:ses:${AWS::Region}:${AWS::AccountId}:configuration-set/tracking
+ Events:
+ DynamoDBEvent:
+ Type: EventBridgeRule
+ Properties:
+ Pattern:
+ resources: [!Ref EnrollmentTable]
+ detail-type: [EXPIRE]
+ detail:
+ keys:
+ sk:
+ - schedules#does_not_access
+ - schedules#reminder_no_access_3_days
+
EventIssueCertFunction:
Type: AWS::Serverless::Function
Properties:
diff --git a/enrollments-events/tests/conftest.py b/enrollments-events/tests/conftest.py
index 9bd2934..f4e9815 100644
--- a/enrollments-events/tests/conftest.py
+++ b/enrollments-events/tests/conftest.py
@@ -18,8 +18,6 @@ def pytest_configure():
os.environ['COURSE_TABLE'] = PYTEST_TABLE_NAME
os.environ['ORDER_TABLE'] = PYTEST_TABLE_NAME
os.environ['ENROLLMENT_TABLE'] = PYTEST_TABLE_NAME
- # Post-migration: remove it
- os.environ['OLD_ENROLLMENT_TABLE'] = PYTEST_TABLE_NAME
@dataclass
diff --git a/enrollments-events/tests/events/emails/__init__.py b/enrollments-events/tests/events/emails/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/enrollments-events/tests/events/emails/test_reminder_no_access_3_days.py b/enrollments-events/tests/events/emails/test_reminder_no_access_3_days.py
new file mode 100644
index 0000000..2af4193
--- /dev/null
+++ b/enrollments-events/tests/events/emails/test_reminder_no_access_3_days.py
@@ -0,0 +1,19 @@
+import app.events.emails.reminder_no_access_3_days as app
+from aws_lambda_powertools.utilities.typing import LambdaContext
+
+
+def test_reminder_no_access_3_days(
+ dynamodb_client,
+ dynamodb_seeds,
+ lambda_context: LambdaContext,
+):
+ event = {
+ 'detail': {
+ 'new_image': {
+ 'id': '47ZxxcVBjvhDS5TE98tpfQ',
+ 'sk': 'schedules#reminder_no_access_3_days',
+ }
+ }
+ }
+
+ assert app.lambda_handler(event, lambda_context)
diff --git a/http-api/app/routes/courses/__init__.py b/http-api/app/routes/courses/__init__.py
index ef9d0c7..7eda42a 100644
--- a/http-api/app/routes/courses/__init__.py
+++ b/http-api/app/routes/courses/__init__.py
@@ -31,7 +31,7 @@ user_layer = DynamoDBPersistenceLayer(USER_TABLE, dynamodb_client)
)
def get_courses():
event = router.current_event
- query = event.get_query_string_value('query', '')
+ query = event.get_query_string_value('q', '')
sort = event.get_query_string_value('sort', 'create_date:desc')
page = int(event.get_query_string_value('page', '1'))
hits_per_page = int(event.get_query_string_value('hitsPerPage', '25'))
diff --git a/order-events/app/config.py b/order-events/app/config.py
index 7b13358..5d3ab5d 100644
--- a/order-events/app/config.py
+++ b/order-events/app/config.py
@@ -5,7 +5,7 @@ ORDER_TABLE: str = os.getenv('ORDER_TABLE') # type: ignore
COURSE_TABLE: str = os.getenv('COURSE_TABLE') # type: ignore
ENROLLMENT_TABLE: str = os.getenv('ENROLLMENT_TABLE') # type: ignore
-# Post-migration: remove the lines below
+# Post-migration: Remove the following lines
if os.getenv('AWS_LAMBDA_FUNCTION_NAME'):
SQLITE_DATABASE = 'courses_export_2025-06-18_110214.db'
else:
diff --git a/order-events/app/events/assign_tenant_cnpj.py b/order-events/app/events/assign_tenant_cnpj.py
index f795c55..29ac53b 100644
--- a/order-events/app/events/assign_tenant_cnpj.py
+++ b/order-events/app/events/assign_tenant_cnpj.py
@@ -64,4 +64,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
},
)
+ logger.info('IDs updated')
+
return True
diff --git a/order-events/app/events/remove_slots_if_canceled.py b/order-events/app/events/remove_slots_if_canceled.py
index fc9d6f0..527bfef 100644
--- a/order-events/app/events/remove_slots_if_canceled.py
+++ b/order-events/app/events/remove_slots_if_canceled.py
@@ -32,7 +32,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
result = enrollment_layer.collection.query(
KeyPair(
- # Post-migration: uncomment the following line
+ # Post-migration: Uncomment the following line
# ComposeKey(tenant_id, prefix='slots#org'),
ComposeKey(tenant_id, prefix='vacancies'),
order_id,
@@ -45,10 +45,12 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
for pair in result['items']:
batch.delete_item(
Key={
- # Post-migration: rename `vacancies` to `slots#org`
+ # Post-migration: Rename `vacancies` to `slots#org`
'id': {'S': ComposeKey(pair['id'], prefix='vacancies')},
'sk': {'S': pair['sk']},
}
)
+ logger.info('Slots deleted')
+
return True
diff --git a/order-events/app/events/set_as_expired.py b/order-events/app/events/set_as_expired.py
index 4c6d0ef..4432676 100644
--- a/order-events/app/events/set_as_expired.py
+++ b/order-events/app/events/set_as_expired.py
@@ -6,6 +6,7 @@ from aws_lambda_powertools.utilities.data_classes import (
from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dateutils import now
from layercake.dynamodb import (
+ ComposeKey,
DynamoDBPersistenceLayer,
KeyPair,
)
@@ -41,7 +42,23 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
)
except Exception:
logger.info('Failed to update status to EXPIRED', order_id=new_image['id'])
+ order_layer.put_item(
+ item={
+ 'id': new_image['id'],
+ 'sk': ComposeKey('failed', prefix=new_image['sk']),
+ 'created_at': now_,
+ }
+ )
+
return False
else:
logger.info('Status set to EXPIRED', order_id=new_image['id'])
+ order_layer.put_item(
+ item={
+ 'id': new_image['id'],
+ 'sk': ComposeKey('completed', prefix=new_image['sk']),
+ 'created_at': now_,
+ }
+ )
+
return True
diff --git a/order-events/app/events/stopgap/patch_items.py b/order-events/app/events/stopgap/patch_items.py
index 6fa9599..35f9f9b 100644
--- a/order-events/app/events/stopgap/patch_items.py
+++ b/order-events/app/events/stopgap/patch_items.py
@@ -1,5 +1,6 @@
import json
import sqlite3
+from decimal import ROUND_HALF_UP, Decimal
from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import (
@@ -33,9 +34,15 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
for item in items:
course = _get_course(item['id'])
+ unit_price = Decimal(item['unit_price'])
new_items.append(
item
+ | {
+ 'unit_price': unit_price.quantize(
+ Decimal('0.01'), rounding=ROUND_HALF_UP
+ )
+ }
| (
{
'id': course.get('metadata__betaeducacao_id'),
diff --git a/order-events/tests/events/stopgap/test_schedule_expired.py b/order-events/tests/events/stopgap/test_schedule_expired.py
index e8625cc..2e90ad2 100644
--- a/order-events/tests/events/stopgap/test_schedule_expired.py
+++ b/order-events/tests/events/stopgap/test_schedule_expired.py
@@ -21,11 +21,14 @@ def test_schedule_expired(
}
}
- assert app.lambda_handler(event, lambda_context)
- assert {
+ assert app.lambda_handler(event, lambda_context) # type: ignore
+
+ expected = {
'sk': 'schedules#set_as_expired',
'ttl': Decimal('1751715285'),
'id': '123',
- } == dynamodb_persistence_layer.get_item(
+ }
+ r = dynamodb_persistence_layer.get_item(
key=KeyPair('123', 'schedules#set_as_expired')
)
+ assert r['ttl'] == expected['ttl']
diff --git a/order-events/tests/events/test_assign_tenant_cnpj.py b/order-events/tests/events/test_assign_tenant_cnpj.py
index 93094bd..290c213 100644
--- a/order-events/tests/events/test_assign_tenant_cnpj.py
+++ b/order-events/tests/events/test_assign_tenant_cnpj.py
@@ -21,8 +21,7 @@ def test_assign_tenant_cnpj(
assert app.lambda_handler(event, lambda_context) # type: ignore
- result = dynamodb_persistence_layer.collection.query(
+ r = dynamodb_persistence_layer.collection.query(
PartitionKey('9omWNKymwU5U4aeun6mWzZ')
)
-
- assert 3 == len(result['items'])
+ assert 2 == len(r['items'])
diff --git a/order-events/tests/seeds.jsonl b/order-events/tests/seeds.jsonl
index 14ced81..af1097c 100644
--- a/order-events/tests/seeds.jsonl
+++ b/order-events/tests/seeds.jsonl
@@ -1,7 +1,6 @@
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "metadata#payment_policy"}, "due_days": {"N": "90"}}
{"id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}, "sk": {"S": "metadata#billing_policy"}, "billing_day": {"N": "1"}, "payment_method": {"S": "PIX"}}
{"id": {"S": "9omWNKymwU5U4aeun6mWzZ"}, "sk": {"S": "0"}, "total": {"N": "398"}, "status": {"S": "PENDING"}, "payment_method": {"S": "MANUAL"}, "tenant": {"S": "cJtK9SsnJhKPyxESe7g3DG"}}
-{"id": {"S": "9omWNKymwU5U4aeun6mWzZ"}, "sk": {"S": "0"}, "total": {"N": "398"}, "status": {"S": "PENDING"}, "payment_method": {"S": "MANUAL"}, "tenant": {"S": "cJtK9SsnJhKPyxESe7g3DG"}}
{"id": {"S": "cnpj"}, "sk": {"S": "15608435000190"}, "user_id": {"S": "cJtK9SsnJhKPyxESe7g3DG"}}
{"id": {"S": "email"}, "sk": {"S": "sergio@somosbeta.com.br"}, "user_id": {"S": "5OxmMjL-ujoR5IMGegQz"}}
{"id": {"S": "5OxmMjL-ujoR5IMGegQz"}, "sk": {"S": "0"}, "name": {"S": "Sérgio R Siqueira"}}