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,
table_name=ORDER_TABLE,
)
transact.put(
item={
'id': seat.order_id,
'sk': f'ENROLLMENT#{enrollment.id}',
'course': course.model_dump(),
'user': user.model_dump(),
'status': 'EXECUTED',
'executed_at': now_,
'created_at': now_,
transact.update(
key=KeyPair(
pk=str(seat.order_id),
sk=f'ENROLLMENT#{enrollment.id}',
),
update_expr='SET course = :course, \
#user = :user, \
#status = :executed, \
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,
)
@@ -240,6 +250,15 @@ def enroll_now(enrollment: Enrollment, context: Context):
cond_expr='attribute_exists(sk)',
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(
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.exceptions import NotFoundError
from aws_lambda_powertools.event_handler.openapi.params import Body, Query
from layercake.dateutils import now
from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair, PartitionKey
from pydantic import FutureDatetime
from api_gateway import JSONResponse
from boto3clients import dynamodb_client
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__)
router = Router()
@@ -65,6 +74,7 @@ def proceed(
scheduled_for: Annotated[FutureDatetime, Body(embed=True)],
lock_hash: Annotated[str, Body(embed=True)],
):
now_ = now()
pk = f'SCHEDULED#ORG#{org_id}'
sk = f'{scheduled_for.isoformat()}#{lock_hash}'
@@ -72,10 +82,13 @@ def proceed(
KeyPair(pk, sk),
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 = {
'created_by': router.context['user'],
'org': Org(id=org_id, name=scheduled['org_name']),
'created_by': created_by,
'org': org,
}
if billing_day:
@@ -86,12 +99,26 @@ def proceed(
Enrollment(
user=scheduled['user'],
course=scheduled['course'],
seat=scheduled.get('seat'),
seat=seat,
),
ctx,
)
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('LOCK#SCHEDULED', lock_hash))
except Exception:

View File

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

View File

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

View File

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

View File

@@ -14,8 +14,6 @@ from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE, ORDER_TABLE
from enrollment import (
Enrollment,
Kind,
LinkedEntity,
Seat,
Subscription,
enroll,
@@ -54,21 +52,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
)
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(
enrollment,
org={
@@ -82,7 +65,6 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
scheduled_at=datetime.fromisoformat(old_image['scheduled_at']),
# Transfer the deduplication window if it exists
deduplication_window={'offset_days': offset_days} if offset_days else None,
linked_entities=linked_entities,
persistence_layer=dyn,
)
@@ -105,17 +87,49 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
),
)
except Exception as exc:
dyn.put_item(
item={
'id': old_image['id'],
'sk': f'{sk}#FAILED',
'cause': {
'type': type(exc).__name__,
'message': str(exc),
},
'snapshot': old_image,
'created_at': now_,
}
)
with dyn.transact_writer() as transact:
transact.put(
item={
'id': old_image['id'],
'sk': f'{sk}#FAILED',
'cause': {
'type': type(exc).__name__,
'message': str(exc),
},
'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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -50,4 +50,8 @@
{"id": "SUBSCRIPTION", "sk": "ORG#123"}
// 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},
}
)
# Relationships between this enrollment and its related entities
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
# Enrollment should know where it comes from
transact.put(
item={
'id': enrollment.id,
'sk': f'LINKED_ENTITIES#PARENT#ORDER#{order_id}',
'sk': f'LINKED_ENTITY#PARENT#ORDER#{order_id}',
'created_at': now_,
},
cond_expr='attribute_not_exists(sk)',