add enroll to subscribed

This commit is contained in:
2025-12-08 16:48:31 -03:00
parent 1ff2634bc0
commit 93d96486ff
11 changed files with 148 additions and 108 deletions

View File

@@ -40,8 +40,8 @@ class DeduplicationConflictError(ConflictError): ...
class User(BaseModel): class User(BaseModel):
id: str | UUID4 id: str | UUID4
name: NameStr name: NameStr
cpf: CpfStr
email: EmailStr email: EmailStr
cpf: CpfStr
class Course(BaseModel): class Course(BaseModel):
@@ -123,9 +123,9 @@ def enroll(
Context = TypedDict( Context = TypedDict(
'Context', 'Context',
{ {
'created_by': Authenticated,
'org': Org, 'org': Org,
'terms': SubscriptionTerms, 'terms': SubscriptionTerms,
'created_by': Authenticated,
}, },
) )
@@ -251,14 +251,12 @@ def enroll_later(enrollment: Enrollment, context: Context):
'sk': f'{scheduled_for.isoformat()}#{lock_hash}', 'sk': f'{scheduled_for.isoformat()}#{lock_hash}',
'user': user.model_dump(), 'user': user.model_dump(),
'course': course.model_dump(), 'course': course.model_dump(),
'org': org.model_dump(), 'org_name': org.name,
'created_by': { 'created_by': {
'id': created_by.id, 'id': created_by.id,
'name': created_by.name, 'name': created_by.name,
}, },
'subscription_covered': { 'subscription_billing_day': subscription_terms.billing_day,
'billing_day': subscription_terms.billing_day,
},
'ttl': ttl(start_dt=scheduled_for), 'ttl': ttl(start_dt=scheduled_for),
'created_at': now_, 'created_at': now_,
} }

View File

@@ -16,8 +16,7 @@ dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@router.get('/<org_id>/enrollments/scheduled') @router.get('/<org_id>/enrollments/scheduled')
def scheduled(org_id: str, start_key: Annotated[str | None, Query] = None): def scheduled(org_id: str, start_key: Annotated[str | None, Query] = None):
return dyn.collection.query( return dyn.collection.query(
# Post-migration: rename `scheduled_items` to `SCHEDULED#ORG#{org_id}` key=PartitionKey(f'SCHEDULED#ORG#{org_id}'),
key=PartitionKey(f'scheduled_items#{org_id}'),
start_key=start_key, start_key=start_key,
limit=150, limit=150,
) )

View File

@@ -1,5 +1,3 @@
import json
import pprint
from http import HTTPMethod from http import HTTPMethod
from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey
@@ -26,14 +24,14 @@ def test_enroll(
'user': { 'user': {
'id': '15bacf02-1535-4bee-9022-19d106fd7518', 'id': '15bacf02-1535-4bee-9022-19d106fd7518',
'name': 'Eddie Vedder', 'name': 'Eddie Vedder',
'email': 'sergio@somosbeta.com.br', 'email': 'eddie@pearljam.band',
'cpf': '07879819908', 'cpf': '07879819908',
}, },
'course': { 'course': {
'id': 'c27d1b4f-575c-4b6b-82a1-9b91ff369e0b', 'id': 'c27d1b4f-575c-4b6b-82a1-9b91ff369e0b',
'name': 'NR-10', 'name': 'NR-18 PEMT Plataforma Móvel de Trabalho Aéreo',
'access_period': '360', 'access_period': '360',
'unit_price': '100.30', 'unit_price': '149',
}, },
'scheduled_for': '2028-01-01', 'scheduled_for': '2028-01-01',
}, },
@@ -61,11 +59,14 @@ def test_enroll(
lambda_context, lambda_context,
) )
body = json.loads(r['body'])
pprint.pp(body)
enrolled = dynamodb_persistence_layer.collection.query( enrolled = dynamodb_persistence_layer.collection.query(
PartitionKey('d0349bbe-cef3-44f7-b20e-3cb4476ab4c5') PartitionKey('d0349bbe-cef3-44f7-b20e-3cb4476ab4c5')
) )
pprint.pp(enrolled) assert len(enrolled['items']) == 7
scheduled = dynamodb_persistence_layer.collection.query(
PartitionKey('SCHEDULED#ORG#2a8963fc-4694-4fe2-953a-316d1b10f1f5')
)
assert len(scheduled['items']) == 1

View File

@@ -1,6 +1,8 @@
import type { Route } from './+types/route' import type { Route } from './+types/route'
import { isValidCNPJ } from '@brazilian-utils/brazilian-utils'
import { useForm } from 'react-hook-form' import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { PatternFormat } from 'react-number-format' import { PatternFormat } from 'react-number-format'
import { import {
@@ -13,9 +15,22 @@ import {
} from '@repo/ui/components/ui/form' } from '@repo/ui/components/ui/form'
import { Input } from '@repo/ui/components/ui/input' import { Input } from '@repo/ui/components/ui/input'
import { Button } from '@repo/ui/components/ui/button' import { Button } from '@repo/ui/components/ui/button'
import z from 'zod'
const formSchema = z.object({
cnpj: z
.string('CNPJ obrigatório')
.refine(isValidCNPJ, { message: 'CNPJ inválido' })
})
export type Schema = z.infer<typeof formSchema>
export default function Route({}: Route.ComponentProps) { export default function Route({}: Route.ComponentProps) {
const form = useForm() const form = useForm({ resolver: zodResolver(formSchema) })
const { handleSubmit, control } = form
const onSubmit = async (data: Schema) => {
console.log(data)
}
return ( return (
<> <>
@@ -26,17 +41,18 @@ export default function Route({}: Route.ComponentProps) {
</div> </div>
<Form {...form}> <Form {...form}>
<form className="space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<FormField <FormField
control={form.control} control={control}
name="cnpj" name="cnpj"
render={({ field: { onChange, ref, ...props } }) => ( render={({ field: { onChange, ref } }) => (
<FormItem className="grid gap-3"> <FormItem className="grid gap-3">
<FormLabel>CNPJ</FormLabel> <FormLabel>CNPJ</FormLabel>
<FormControl> <FormControl>
<PatternFormat <PatternFormat
format="##.###.###/####-##" format="##.###.###/####-##"
mask="_" mask="_"
autoFocus={true}
placeholder="__.___.__/____-__" placeholder="__.___.__/____-__"
customInput={Input} customInput={Input}
getInputRef={ref} getInputRef={ref}
@@ -50,7 +66,7 @@ export default function Route({}: Route.ComponentProps) {
)} )}
/> />
<Button type="submit" className="w-full"> <Button type="submit" className="w-full cursor-pointer">
Continuar Continuar
</Button> </Button>
</form> </form>

View File

@@ -15,15 +15,15 @@ export function meta({}: Route.MetaArgs) {
export default function Route({}: Route.ComponentProps) { export default function Route({}: Route.ComponentProps) {
return ( return (
<> <>
<div className="flex min-h-svh w-full items-center justify-center p-6 md:p-10 relative"> <div className="min-h-dvh flex items-center justify-center overflow-auto">
<Link <Link
to="/" to="/"
className="flex items-center gap-0.5 absolute top-5 left-5 text-sm z-2" className="absolute left-4 top-6 flex items-center gap-1 z-10 hover:underline"
> >
<ChevronLeftIcon className="size-5" /> Voltar <ChevronLeftIcon className="size-5" /> Voltar
</Link> </Link>
<div className="w-full max-w-xs relative max-sm:mt-10 z-1 space-y-6"> <div className="w-full max-w-xs pt-8 relative z-10 space-y-6 px-4">
<div className="flex justify-center"> <div className="flex justify-center">
<div className="border border-white/15 bg-white/5 px-2.5 py-3 rounded-xl"> <div className="border border-white/15 bg-white/5 px-2.5 py-3 rounded-xl">
<img src={logo} alt="EDUSEG®" className="block size-12" /> <img src={logo} alt="EDUSEG®" className="block size-12" />

View File

@@ -1,37 +1,74 @@
from abc import ABC from abc import ABC
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta from datetime import datetime, timedelta
from enum import Enum from enum import Enum
from typing import 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 from layercake.dynamodb import DynamoDBPersistenceLayer
from layercake.extra_types import CpfStr, NameStr
from layercake.strutils import md5_hash from layercake.strutils import md5_hash
from pydantic import (
UUID4,
BaseModel,
ConfigDict,
EmailStr,
Field,
)
from typing_extensions import NotRequired
from config import DEDUP_WINDOW_OFFSET_DAYS from config import DEDUP_WINDOW_OFFSET_DAYS
from schemas import Enrollment
Org = TypedDict(
'Org',
{
'org_id': str,
'name': str,
},
)
DeduplicationWindow = TypedDict( class User(BaseModel):
'DeduplicationWindow', model_config = ConfigDict(arbitrary_types_allowed=True)
{
'offset_days': int, id: UUID4 | str
}, name: NameStr
) email: EmailStr
email_verified: bool = False
cpf: CpfStr | None = None
class Course(BaseModel):
id: UUID4 | str
name: str
access_period: int = 90 # 3 months
class Enrollment(BaseModel):
id: UUID4 | str = Field(default_factory=uuid4)
user: User
course: Course
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})
CreatedBy = TypedDict('CreatedBy', {'id': str, 'name': str})
DeduplicationWindow = TypedDict('DeduplicationWindow', {'offset_days': int})
Subscription = TypedDict( Subscription = TypedDict(
'Subscription', 'Subscription',
{ {
'org_id': str, 'org_id': str,
'billing_day': int, 'billing_day': int,
'billing_period': str, 'billing_period': NotRequired[str],
}, },
) )
@@ -58,6 +95,8 @@ def enroll(
*, *,
org: Org | None = None, org: Org | None = None,
subscription: Subscription | None = None, subscription: Subscription | None = None,
created_by: CreatedBy | None = None,
scheduled_at: datetime | None = None,
linked_entities: frozenset[LinkedEntity] = frozenset(), linked_entities: frozenset[LinkedEntity] = frozenset(),
deduplication_window: DeduplicationWindow | None = None, deduplication_window: DeduplicationWindow | None = None,
persistence_layer: DynamoDBPersistenceLayer, persistence_layer: DynamoDBPersistenceLayer,
@@ -65,7 +104,7 @@ def enroll(
now_ = now() now_ = now()
user = enrollment.user user = enrollment.user
course = enrollment.course course = enrollment.course
lock_hash = md5_hash('%s%s' % (user.id, course.id)) lock_hash = md5_hash(f'{user.id}{course.id}')
access_expires_at = now_ + timedelta(days=course.access_period) access_expires_at = now_ + timedelta(days=course.access_period)
with persistence_layer.transact_writer() as transact: with persistence_layer.transact_writer() as transact:
@@ -76,8 +115,9 @@ def enroll(
'access_expires_at': access_expires_at, 'access_expires_at': access_expires_at,
**enrollment.model_dump(), **enrollment.model_dump(),
} }
| ({'org_id': org['org_id']} if org else {})
| ({'subscription_covered': True} if subscription else {}) | ({'subscription_covered': True} if subscription else {})
| ({'org_id': org['org_id']} if org else {}), | ({'scheduled_at': scheduled_at} if scheduled_at else {})
) )
# Relationships between this enrollment and its related entities # Relationships between this enrollment and its related entities
@@ -123,6 +163,16 @@ def enroll(
| subscription, | subscription,
) )
if created_by:
transact.put(
item={
'id': enrollment.id,
'sk': 'CREATED_BY',
'created_by': created_by,
'created_at': now_,
}
)
# Prevents the user from enrolling in the same course again until # Prevents the user from enrolling in the same course again until
# the deduplication window expires or is removed. # the deduplication window expires or is removed.
offset_days = ( offset_days = (

View File

@@ -1,5 +1,3 @@
from uuid import uuid4
from aws_lambda_powertools import Logger from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import ( from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent, EventBridgeEvent,
@@ -19,11 +17,13 @@ from layercake.dynamodb import (
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import COURSE_TABLE, ENROLLMENT_TABLE, ORDER_TABLE from config import COURSE_TABLE, ENROLLMENT_TABLE, ORDER_TABLE
from enrollment import ( from enrollment import (
Course,
Enrollment,
Kind, Kind,
LinkedEntity, LinkedEntity,
User,
enroll, enroll,
) )
from schemas import Course, Enrollment, User
logger = Logger(__name__) logger = Logger(__name__)
order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) order_layer = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client)
@@ -82,11 +82,10 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
) )
def _handler(record: Course, context: dict) -> Enrollment: def _handler(course: Course, context: dict) -> Enrollment:
enrollment = Enrollment( enrollment = Enrollment(
id=uuid4(),
user=context['user'], user=context['user'],
course=record, course=course,
) )
enroll( enroll(

View File

@@ -1,3 +1,5 @@
from datetime import datetime
from aws_lambda_powertools import Logger from aws_lambda_powertools import Logger
from aws_lambda_powertools.utilities.data_classes import ( from aws_lambda_powertools.utilities.data_classes import (
EventBridgeEvent, EventBridgeEvent,
@@ -7,9 +9,8 @@ from aws_lambda_powertools.utilities.typing import LambdaContext
from layercake.dynamodb import DynamoDBPersistenceLayer from layercake.dynamodb import DynamoDBPersistenceLayer
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ( from config import ENROLLMENT_TABLE
ENROLLMENT_TABLE, from enrollment import Enrollment, enroll
)
logger = Logger(__name__) logger = Logger(__name__)
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@@ -21,5 +22,29 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
old_image = event.detail['old_image'] old_image = event.detail['old_image']
# Key pattern `SCHEDULED#ORG#{org_id}` # Key pattern `SCHEDULED#ORG#{org_id}`
*_, org_id = old_image['id'].split('#') *_, org_id = old_image['id'].split('#')
offset_days = old_image.get('dedup_window_offset_days')
billing_day = old_image.get('subscription_billing_day')
enrollment = Enrollment(
course=old_image['course'],
user=old_image['user'],
)
return True return enroll(
enrollment,
org={
'org_id': org_id,
'name': old_image['org_name'],
},
subscription=(
{
'org_id': org_id,
'billing_day': int(billing_day),
}
if billing_day
else None
),
scheduled_at=datetime.fromisoformat(old_image['created_at']),
# Transfer the deduplication window if it exists
deduplication_window={'offset_days': offset_days} if offset_days else None,
persistence_layer=dyn,
)

View File

@@ -10,8 +10,7 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, SortKey, TransactKey
from boto3clients import dynamodb_client from boto3clients import dynamodb_client
from config import ENROLLMENT_TABLE from config import ENROLLMENT_TABLE
from enrollment import Kind, LinkedEntity, enroll from enrollment import Course, Enrollment, Kind, LinkedEntity, User, enroll
from schemas import Course, Enrollment, User
logger = Logger(__name__) logger = Logger(__name__)
dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client) dyn = DynamoDBPersistenceLayer(ENROLLMENT_TABLE, dynamodb_client)
@@ -24,16 +23,16 @@ def lambda_handler(event: EventBridgeEvent, context: LambdaContext) -> bool:
metadata = dyn.collection.get_items( metadata = dyn.collection.get_items(
TransactKey(new_image['id']) TransactKey(new_image['id'])
+ SortKey( + SortKey(
'METADATA#SUBSCRIPTION_COVERED', sk='METADATA#SUBSCRIPTION_COVERED',
rename_key='subscription', rename_key='subscription',
) )
+ SortKey( + SortKey(
'METADATA#DEDUPLICATION_WINDOW', sk='METADATA#DEDUPLICATION_WINDOW',
path_spec='offset_days', path_spec='offset_days',
rename_key='dedup_window_offset_days', rename_key='dedup_window_offset_days',
) )
+ SortKey( + SortKey(
'ORG', sk='ORG',
rename_key='org', rename_key='org',
), ),
flatten_top=False, flatten_top=False,

View File

@@ -1,47 +0,0 @@
from typing import Any, Literal
from uuid import uuid4
from layercake.extra_types import CpfStr, NameStr
from pydantic import (
UUID4,
BaseModel,
ConfigDict,
EmailStr,
Field,
)
class User(BaseModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
id: UUID4 | str = Field(default_factory=uuid4)
name: NameStr
email: EmailStr
email_verified: bool = False
cpf: CpfStr | None = None
class Course(BaseModel):
id: UUID4 | str = Field(default_factory=uuid4)
name: str
access_period: int = 90 # 3 months
class Enrollment(BaseModel):
id: UUID4 | str = Field(default_factory=uuid4)
user: User
course: Course
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,
)

View File

@@ -29,7 +29,7 @@ def test_append_cert(
'cert_expires_at': cert_expires_at.isoformat(), 'cert_expires_at': cert_expires_at.isoformat(),
'user': { 'user': {
'id': '1234', 'id': '1234',
'name': 'Tobias Summit', 'name': 'Tobias Sammit',
}, },
'org_id': '1e2eaf0e-e319-49eb-ab33-1ddec156dc94', 'org_id': '1e2eaf0e-e319-49eb-ab33-1ddec156dc94',
'created_at': '2025-01-01T00:00:00-03:06', 'created_at': '2025-01-01T00:00:00-03:06',
@@ -81,7 +81,7 @@ def test_report_exists(
'cert_expires_at': '2025-07-02T00:00:00-03:06', 'cert_expires_at': '2025-07-02T00:00:00-03:06',
'user': { 'user': {
'id': '1234', 'id': '1234',
'name': 'Tobias Summit', 'name': 'Tobias Sammit',
}, },
'org_id': '00237409-9384-4692-9be5-b4443a41e1c4', 'org_id': '00237409-9384-4692-9be5-b4443a41e1c4',
'created_at': '2025-01-01T00:00:00-03:06', 'created_at': '2025-01-01T00:00:00-03:06',