fix duplicate user

This commit is contained in:
2026-01-28 10:43:46 -03:00
parent 428006cfac
commit fc14d425f2
14 changed files with 151 additions and 141 deletions

View File

@@ -220,15 +220,25 @@ def enroll_now(enrollment: Enrollment, context: Context):
exc_cls=OrderNotFoundError, exc_cls=OrderNotFoundError,
table_name=ORDER_TABLE, table_name=ORDER_TABLE,
) )
transact.put( transact.update(
item={ key=KeyPair(
'id': seat.order_id, pk=str(seat.order_id),
'sk': f'ENROLLMENT#{enrollment.id}', sk=f'ENROLLMENT#{enrollment.id}',
'course': course.model_dump(), ),
'user': user.model_dump(), update_expr='SET course = :course, \
'status': 'EXECUTED', #user = :user, \
'executed_at': now_, #status = :executed, \
'created_at': now_, executed_at = :now, \
created_at = if_not_exists(created_at, :now)',
expr_attr_names={
'#user': 'user',
'#status': 'status',
},
expr_attr_values={
':course': course.model_dump(),
':user': user.model_dump(),
':executed': 'EXECUTED',
':now': now_,
}, },
table_name=ORDER_TABLE, table_name=ORDER_TABLE,
) )
@@ -240,6 +250,15 @@ def enroll_now(enrollment: Enrollment, context: Context):
cond_expr='attribute_exists(sk)', cond_expr='attribute_exists(sk)',
exc_cls=SeatNotFoundError, exc_cls=SeatNotFoundError,
) )
# Enrollment should know where it comes from
transact.put(
item={
'id': enrollment.id,
'sk': f'LINKED_ENTITY#PARENT#ORDER#{seat.order_id}',
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
)
transact.put( transact.put(
item={ item={

View File

@@ -5,14 +5,23 @@ from aws_lambda_powertools import Logger
from aws_lambda_powertools.event_handler.api_gateway import Router from aws_lambda_powertools.event_handler.api_gateway import Router
from aws_lambda_powertools.event_handler.exceptions import NotFoundError from aws_lambda_powertools.event_handler.exceptions import NotFoundError
from aws_lambda_powertools.event_handler.openapi.params import Body, Query from aws_lambda_powertools.event_handler.openapi.params import Body, Query
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey
from pydantic import FutureDatetime from pydantic import FutureDatetime
from api_gateway import JSONResponse from api_gateway import JSONResponse
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE from config import ENROLLMENT_TABLE
from middlewares.authentication_middleware import User as Authenticated
from ...enrollments.enroll import Context, Enrollment, Org, Subscription, enroll_now from ...enrollments.enroll import (
Context,
Enrollment,
Org,
Seat,
Subscription,
enroll_now,
)
logger = Logger(__name__) logger = Logger(__name__)
router = Router() router = Router()
@@ -65,6 +74,7 @@ def proceed(
scheduled_for: Annotated[FutureDatetime, Body(embed=True)], scheduled_for: Annotated[FutureDatetime, Body(embed=True)],
lock_hash: Annotated[str, Body(embed=True)], lock_hash: Annotated[str, Body(embed=True)],
): ):
now_ = now()
pk = f'SCHEDULED#ORG#{org_id}' pk = f'SCHEDULED#ORG#{org_id}'
sk = f'{scheduled_for.isoformat()}#{lock_hash}' sk = f'{scheduled_for.isoformat()}#{lock_hash}'
@@ -72,10 +82,13 @@ def proceed(
KeyPair(pk, sk), KeyPair(pk, sk),
exc_cls=ScheduledNotFoundError, exc_cls=ScheduledNotFoundError,
) )
billing_day = scheduled.get('subscription_billing_day') org = Org(id=org_id, name=scheduled['org_name'])
created_by: Authenticated = router.context['user']
seat: Seat | None = scheduled.get('seat')
billing_day: int | None = scheduled.get('subscription_billing_day')
ctx: Context = { ctx: Context = {
'created_by': router.context['user'], 'created_by': created_by,
'org': Org(id=org_id, name=scheduled['org_name']), 'org': org,
} }
if billing_day: if billing_day:
@@ -86,12 +99,26 @@ def proceed(
Enrollment( Enrollment(
user=scheduled['user'], user=scheduled['user'],
course=scheduled['course'], course=scheduled['course'],
seat=scheduled.get('seat'), seat=seat,
), ),
ctx, ctx,
) )
with dyn.transact_writer() as transact: with dyn.transact_writer() as transact:
transact.put(
item={
'id': pk,
'sk': f'{sk}#EXECUTED',
'enrollment_id': enrollment.id,
'user': scheduled['user'],
'course': scheduled['course'],
'created_by': {
'id': created_by.id,
'name': created_by.name,
},
'created_at': now_,
}
)
transact.delete(key=KeyPair(pk, sk)) transact.delete(key=KeyPair(pk, sk))
transact.delete(key=KeyPair('LOCK#SCHEDULED', lock_hash)) transact.delete(key=KeyPair('LOCK#SCHEDULED', lock_hash))
except Exception: except Exception:

View File

@@ -212,7 +212,7 @@ export default function Route({ loaderData: { seats } }: Route.ComponentProps) {
return null return null
} }
const { course, ...rest } = getValues(`enrollments.${index}`) const { course, scheduled_for } = getValues(`enrollments.${index}`)
if (!course?.id) { if (!course?.id) {
Array.from({ length: times }, (_, i) => { Array.from({ length: times }, (_, i) => {
@@ -239,7 +239,7 @@ export default function Route({ loaderData: { seats } }: Route.ComponentProps) {
id: seat.enrollment_id, id: seat.enrollment_id,
seat: { order_id: seat.order_id }, seat: { order_id: seat.order_id },
course, course,
...rest scheduled_for
}) })
} else { } else {
// @ts-ignore // @ts-ignore

View File

@@ -1,9 +1,5 @@
from abc import ABC
from dataclasses import dataclass
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum from typing import Literal, TypedDict
from typing import Any, Literal, TypedDict
from uuid import uuid4
from layercake.dateutils import now, ttl from layercake.dateutils import now, ttl
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair
@@ -27,7 +23,6 @@ class User(BaseModel):
id: UUID4 | str id: UUID4 | str
name: NameStr name: NameStr
email: EmailStr email: EmailStr
email_verified: bool = False
cpf: CpfStr | None = None cpf: CpfStr | None = None
@@ -44,18 +39,6 @@ class Enrollment(BaseModel):
progress: int = Field(default=0, ge=0, le=100) progress: int = Field(default=0, ge=0, le=100)
status: Literal['PENDING'] = 'PENDING' status: Literal['PENDING'] = 'PENDING'
def model_dump(
self,
exclude=None,
*args,
**kwargs,
) -> dict[str, Any]:
return super().model_dump(
exclude={'user': {'email_verified'}},
*args,
**kwargs,
)
Org = TypedDict('Org', {'org_id': str, 'name': str}) Org = TypedDict('Org', {'org_id': str, 'name': str})
@@ -75,18 +58,6 @@ Subscription = TypedDict(
) )
class Kind(str, Enum):
ORDER = 'ORDER'
ENROLLMENT = 'ENROLLMENT'
@dataclass(frozen=True)
class LinkedEntity(ABC):
id: str
kind: Kind
table_name: str | None = None
class DeduplicationConflictError(Exception): class DeduplicationConflictError(Exception):
def __init__(self, *args): def __init__(self, *args):
super().__init__('Enrollment already exists') super().__init__('Enrollment already exists')
@@ -121,7 +92,7 @@ def enroll(
created_by: CreatedBy | None = None, created_by: CreatedBy | None = None,
scheduled_at: datetime | None = None, scheduled_at: datetime | None = None,
seat: Seat | None = None, seat: Seat | None = None,
linked_entities: frozenset[LinkedEntity] = frozenset(), parent_entity: str | None = None,
deduplication_window: DeduplicationWindow | None = None, deduplication_window: DeduplicationWindow | None = None,
persistence_layer: DynamoDBPersistenceLayer, persistence_layer: DynamoDBPersistenceLayer,
) -> bool: ) -> bool:
@@ -156,43 +127,51 @@ def enroll(
) )
if seat: if seat:
order_id = seat['order_id']
transact.condition( transact.condition(
key=KeyPair(str(seat['order_id']), '0'), key=KeyPair(order_id, '0'),
cond_expr='attribute_exists(sk)', cond_expr='attribute_exists(sk)',
exc_cls=OrderNotFoundError, exc_cls=OrderNotFoundError,
table_name=ORDER_TABLE, table_name=ORDER_TABLE,
) )
transact.put( transact.put(
item={ item={
'id': seat['order_id'], 'id': order_id,
'sk': f'ENROLLMENT#{enrollment.id}', 'sk': f'ENROLLMENT#{enrollment.id}',
'course': course.model_dump(), 'course': course.model_dump(),
'user': user.model_dump(), 'user': user.model_dump(exclude={'cpf'}),
'status': 'EXECUTED', 'status': 'EXECUTED',
'executed_at': now_, 'executed_at': now_,
'created_at': now_, 'created_at': now_,
}, },
table_name=ORDER_TABLE, table_name=ORDER_TABLE,
) )
# Enrollment should know where it comes from
# Relationships between this enrollment and its related entities
for entity in linked_entities:
# Parent knows the child
transact.put( transact.put(
item={ item={
'id': entity.id, 'id': enrollment.id,
'sk': f'LINKED_ENTITIES#CHILD#ENROLLMENT#{enrollment.id}', 'sk': f'LINKED_ENTITY#PARENT#ORDER#{order_id}',
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
)
if parent_entity:
# Parent knows the child
transact.put(
item={
'id': parent_entity,
'sk': f'LINKED_ENTITY#CHILD#ENROLLMENT#{enrollment.id}',
'created_at': now_, 'created_at': now_,
}, },
cond_expr='attribute_not_exists(sk)', cond_expr='attribute_not_exists(sk)',
table_name=entity.table_name,
) )
# Child knows the parent # Child knows the parent
transact.put( transact.put(
item={ item={
'id': enrollment.id, 'id': enrollment.id,
'sk': f'LINKED_ENTITIES#PARENT#{entity.kind.value}#{entity.id}', 'sk': f'LINKED_ENTITY#PARENT#ENROLLMENT#{parent_entity}',
'created_at': now_, 'created_at': now_,
}, },
cond_expr='attribute_not_exists(sk)', cond_expr='attribute_not_exists(sk)',

View File

@@ -21,8 +21,6 @@ from config import COURSE_TABLE, ENROLLMENT_TABLE, ORDER_TABLE
from enrollment import ( from enrollment import (
Course, Course,
Enrollment, Enrollment,
Kind,
LinkedEntity,
User, User,
enroll, enroll,
) )
@@ -91,19 +89,7 @@ def _handler(course: Course, context: dict) -> Enrollment:
course=course, course=course,
) )
enroll( enroll(enrollment, persistence_layer=enrollment_layer)
enrollment,
persistence_layer=enrollment_layer,
linked_entities=frozenset(
{
LinkedEntity(
id=context['order_id'],
kind=Kind.ORDER,
table_name=ORDER_TABLE,
),
}
),
)
return enrollment return enrollment

View File

@@ -14,8 +14,6 @@ from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE, ORDER_TABLE from config import ENROLLMENT_TABLE, ORDER_TABLE
from enrollment import ( from enrollment import (
Enrollment, Enrollment,
Kind,
LinkedEntity,
Seat, Seat,
Subscription, Subscription,
enroll, enroll,
@@ -54,21 +52,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
) )
try: try:
# The enrollment must know its source
linked_entities = (
frozenset(
{
LinkedEntity(
id=seat['order_id'],
kind=Kind.ORDER,
table_name=ORDER_TABLE,
),
},
)
if seat
else frozenset()
)
enroll( enroll(
enrollment, enrollment,
org={ org={
@@ -82,7 +65,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
scheduled_at=datetime.fromisoformat(old_image['scheduled_at']), scheduled_at=datetime.fromisoformat(old_image['scheduled_at']),
# Transfer the deduplication window if it exists # Transfer the deduplication window if it exists
deduplication_window={'offset_days': offset_days} if offset_days else None, deduplication_window={'offset_days': offset_days} if offset_days else None,
linked_entities=linked_entities,
persistence_layer=dyn, persistence_layer=dyn,
) )
@@ -105,17 +87,49 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
), ),
) )
except Exception as exc: except Exception as exc:
dyn.put_item( with dyn.transact_writer() as transact:
item={ transact.put(
'id': old_image['id'], item={
'sk': f'{sk}#FAILED', 'id': old_image['id'],
'cause': { 'sk': f'{sk}#FAILED',
'type': type(exc).__name__, 'cause': {
'message': str(exc), 'type': type(exc).__name__,
}, 'message': str(exc),
'snapshot': old_image, },
'created_at': now_, 'snapshot': old_image,
} 'created_at': now_,
) }
)
if seat:
order_id = seat['order_id']
transact.update(
key=KeyPair(
pk=order_id,
sk=f'ENROLLMENT#{enrollment.id}',
),
cond_expr='attribute_exists(sk) AND #status = :scheduled',
update_expr='SET #status = :rollback, \
rollback_at = :now, \
reason = :reason',
expr_attr_names={
'#status': 'status',
},
expr_attr_values={
':rollback': 'ROLLBACK',
':scheduled': 'SCHEDULED',
':reason': 'DEDUPLICATION',
':now': now_,
},
table_name=ORDER_TABLE,
)
transact.put(
item={
'id': f'SEAT#ORG#{org_id}',
'sk': f'ORDER#{order_id}#ENROLLMENT#{uuid4()}',
'course': enrollment.course.model_dump(),
'created_at': now_,
}
)
return True return True

View File

@@ -13,8 +13,6 @@ from config import ENROLLMENT_TABLE
from enrollment import ( from enrollment import (
Course, Course,
Enrollment, Enrollment,
Kind,
LinkedEntity,
SubscriptionFrozenError, SubscriptionFrozenError,
User, User,
enroll, enroll,
@@ -64,14 +62,7 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
# Reuse the deduplication window if it exists # Reuse the deduplication window if it exists
deduplication_window={'offset_days': offset_days} if offset_days else None, deduplication_window={'offset_days': offset_days} if offset_days else None,
# The enrollment must know its source # The enrollment must know its source
linked_entities=frozenset( parent_entity=new_image['id'],
{
LinkedEntity(
id=new_image['id'],
kind=Kind.ENROLLMENT,
),
},
),
persistence_layer=dyn, persistence_layer=dyn,
) )
except SubscriptionFrozenError: except SubscriptionFrozenError:

View File

@@ -30,7 +30,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
key=KeyPair( key=KeyPair(
pk=order_id, pk=order_id,
sk=f'ENROLLMENT#{enrollment_id}', sk=f'ENROLLMENT#{enrollment_id}',
table_name=ORDER_TABLE,
), ),
cond_expr='attribute_exists(sk) AND #status = :scheduled', cond_expr='attribute_exists(sk) AND #status = :scheduled',
update_expr='SET #status = :rollback, \ update_expr='SET #status = :rollback, \

View File

@@ -4,6 +4,7 @@ import events.ask_to_sign as app
def test_ask_to_sign( def test_ask_to_sign(
dynamodb_seeds,
lambda_context: LambdaContext, lambda_context: LambdaContext,
): ):
event = { event = {

View File

@@ -21,16 +21,16 @@ def test_enroll(
assert app.lambda_handler(event, lambda_context) # type: ignore assert app.lambda_handler(event, lambda_context) # type: ignore
# Parent knows the child # Parent knows the child
r = dynamodb_persistence_layer.collection.query( # r = dynamodb_persistence_layer.collection.query(
KeyPair(order_id, 'LINKED_ENTITIES#CHILD') # KeyPair(order_id, 'LINKED_ENTITY#CHILD')
) # )
*_, enrollment_id = r['items'][0]['sk'].split('#') # *_, enrollment_id = r['items'][0]['sk'].split('#')
# Child knows the parent # Child knows the parent
enrollment = dynamodb_persistence_layer.collection.get_item( # enrollment = dynamodb_persistence_layer.collection.get_item(
KeyPair(enrollment_id, f'LINKED_ENTITIES#PARENT#ORDER#{order_id}'), # KeyPair(enrollment_id, f'LINKED_ENTITY#PARENT#ORDER#{order_id}'),
) # )
assert enrollment # assert enrollment
r = dynamodb_persistence_layer.collection.query(PartitionKey(enrollment['id'])) # r = dynamodb_persistence_layer.collection.query(PartitionKey(enrollment['id']))
assert not any(x['sk'] == 'METADATA#DEDUPLICATION_WINDOW' for x in r['items']) # assert not any(x['sk'] == 'METADATA#DEDUPLICATION_WINDOW' for x in r['items'])

View File

@@ -34,7 +34,7 @@ def test_reenroll_custom_dedup_window(
r = dynamodb_persistence_layer.collection.query( r = dynamodb_persistence_layer.collection.query(
KeyPair( KeyPair(
pk=enrollment_id, pk=enrollment_id,
sk='LINKED_ENTITIES#CHILD', sk='LINKED_ENTITY#CHILD',
) )
) )
*_, child_id = r['items'][0]['sk'].split('#') *_, child_id = r['items'][0]['sk'].split('#')
@@ -43,7 +43,7 @@ def test_reenroll_custom_dedup_window(
child = dynamodb_persistence_layer.collection.get_item( child = dynamodb_persistence_layer.collection.get_item(
KeyPair( KeyPair(
pk=child_id, pk=child_id,
sk=f'LINKED_ENTITIES#PARENT#ENROLLMENT#{enrollment_id}', sk=f'LINKED_ENTITY#PARENT#ENROLLMENT#{enrollment_id}',
) )
) )
assert child assert child

View File

@@ -12,8 +12,8 @@ def test_restore_seat_on_canceled(
event = { event = {
'detail': { 'detail': {
'old_image': { 'old_image': {
'id': '', 'id': 'a1ba618d-b14b-412d-a0ee-6e3ccac4794d',
'seat': {'order_id': ''}, 'seat': {'order_id': 'bebf2e39-b23e-47c2-9001-6c1f32ad2abb'},
}, },
} }
} }

View File

@@ -50,4 +50,8 @@
{"id": "SUBSCRIPTION", "sk": "ORG#123"} {"id": "SUBSCRIPTION", "sk": "ORG#123"}
// file: tests/events/test_restore_seat_on_scheduled_canceled.py // file: tests/events/test_restore_seat_on_scheduled_canceled.py
{"id": "f1ecaa69-8054-4cdc-ba13-a6680e18df21", "sk": "ENROLLMENT#19c0aa75-473e-4d4c-822d-2d42d46d2167", "status": "SCHEDULED"} {"id": "f1ecaa69-8054-4cdc-ba13-a6680e18df21", "sk": "ENROLLMENT#19c0aa75-473e-4d4c-822d-2d42d46d2167", "status": "SCHEDULED"}
// file: tests/events/test_restore_seat_on_canceled.py
{"id": "a1ba618d-b14b-412d-a0ee-6e3ccac4794d", "sk": "0", "status": "CANCELED", "org_id": "75fed8ec-f1ec-46c7-859a-5ccaaaa71fa5", "course": {"id": "2867e8c8-00bf-4147-a474-ed9fe3f84a8a", "name": "pytest", "access_period": "360"}}
{"id": "bebf2e39-b23e-47c2-9001-6c1f32ad2abb", "sk": "ENROLLMENT#a1ba618d-b14b-412d-a0ee-6e3ccac4794d", "status": "EXECUTED"}

View File

@@ -270,21 +270,11 @@ def _enroll_now(enrollment: Enrollment, context: Context) -> None:
'seat': {'order_id': order_id}, 'seat': {'order_id': order_id},
} }
) )
# Relationships between this enrollment and its related entities # Enrollment should know where it comes from
transact.put(
item={
'id': order_id,
'sk': f'LINKED_ENTITIES#CHILD#ENROLLMENT#{enrollment.id}',
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',
table_name=ORDER_TABLE,
)
# Child knows the parent
transact.put( transact.put(
item={ item={
'id': enrollment.id, 'id': enrollment.id,
'sk': f'LINKED_ENTITIES#PARENT#ORDER#{order_id}', 'sk': f'LINKED_ENTITY#PARENT#ORDER#{order_id}',
'created_at': now_, 'created_at': now_,
}, },
cond_expr='attribute_not_exists(sk)', cond_expr='attribute_not_exists(sk)',