diff --git a/api.saladeaula.digital/app/app.py b/api.saladeaula.digital/app/app.py index 2aa5673..ac06575 100644 --- a/api.saladeaula.digital/app/app.py +++ b/api.saladeaula.digital/app/app.py @@ -47,6 +47,7 @@ app.include_router(users.emails, prefix='/users') app.include_router(users.orgs, prefix='/users') app.include_router(users.password, prefix='/users') app.include_router(orders.router, prefix='/orders') +app.include_router(orders.checkout, prefix='/orders') app.include_router(orgs.add, prefix='/orgs') app.include_router(orgs.admins, prefix='/orgs') app.include_router(orgs.billing, prefix='/orgs') @@ -64,7 +65,7 @@ def health(): @app.exception_handler(ServiceError) def exc_error(exc: ServiceError): - # logger.exception(exc) + logger.exception(exc) return JSONResponse( body={ diff --git a/api.saladeaula.digital/app/routes/orders/__init__.py b/api.saladeaula.digital/app/routes/orders/__init__.py index 24da2c8..5d0c698 100644 --- a/api.saladeaula.digital/app/routes/orders/__init__.py +++ b/api.saladeaula.digital/app/routes/orders/__init__.py @@ -7,6 +7,10 @@ from layercake.dynamodb import DynamoDBPersistenceLayer, KeyPair from boto3clients import dynamodb_client from config import ORDER_TABLE +from .checkout import router as checkout + +__all__ = ['checkout'] + router = Router() dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) diff --git a/api.saladeaula.digital/app/routes/orders/checkout.py b/api.saladeaula.digital/app/routes/orders/checkout.py new file mode 100644 index 0000000..b3fafff --- /dev/null +++ b/api.saladeaula.digital/app/routes/orders/checkout.py @@ -0,0 +1,89 @@ +import re +from decimal import Decimal +from http import HTTPStatus +from typing import Annotated, Literal + +from aws_lambda_powertools.event_handler.api_gateway import Router +from aws_lambda_powertools.event_handler.openapi.params import Body +from layercake.dynamodb import DynamoDBPersistenceLayer +from layercake.extra_types import CnpjStr, CpfStr, NameStr +from pydantic import ( + UUID4, + BaseModel, + ConfigDict, + EmailStr, + field_validator, + model_validator, +) + +from api_gateway import JSONResponse +from boto3clients import dynamodb_client +from config import ORDER_TABLE + +router = Router() +dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) + + +class User(BaseModel): + id: UUID4 | str + name: NameStr + + +class Address(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True) + + postcode: str + address1: str + address2: str | None = None + neighborhood: str + city: str + state: str + + @field_validator('postcode') + @classmethod + def ensure_numbers(cls, v: str) -> str: + return re.sub(r'\D', '', v) + + +class Item(BaseModel): + id: UUID4 + name: str + unit_price: Decimal + quantity: int = 1 + + +class Checkout(BaseModel): + model_config = ConfigDict(str_strip_whitespace=True) + + name: str + email: EmailStr + address: Address + payment_method: Literal['PIX', 'CREDIT_CARD', 'BANK_SLIP', 'MANUAL'] + items: tuple[Item, ...] + user: User | None = None + org_id: UUID4 | str | None = None + user_id: UUID4 | str | None = None + cnpj: CnpjStr | None = None + cpf: CpfStr | None = None + + @model_validator(mode='after') + def verify_fields(self): + if not any([self.cnpj, self.cpf]): + raise ValueError('cnpj or cpf is required') + + if self.cnpj is not None: + if self.org_id is None: + raise ValueError('org_id is missing') + + if self.user is None: + raise ValueError('user is missing') + + if self.cpf is not None and self.user_id is None: + raise ValueError('user_id is missing') + + return self + + +@router.post('/') +def checkout(body: Annotated[Checkout, Body()]): + return JSONResponse(status_code=HTTPStatus.CREATED) diff --git a/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py b/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py index 2f7208c..1cf8c58 100644 --- a/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py +++ b/api.saladeaula.digital/app/routes/orgs/enrollments/scheduled.py @@ -11,7 +11,6 @@ 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 Enrollment, Org, Subscription, enroll_now diff --git a/api.saladeaula.digital/tests/routes/orders/__init__.py b/api.saladeaula.digital/tests/routes/orders/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api.saladeaula.digital/tests/routes/orders/test_checkout.py b/api.saladeaula.digital/tests/routes/orders/test_checkout.py new file mode 100644 index 0000000..2868a53 --- /dev/null +++ b/api.saladeaula.digital/tests/routes/orders/test_checkout.py @@ -0,0 +1,91 @@ +from http import HTTPMethod, HTTPStatus + +from layercake.dynamodb import DynamoDBPersistenceLayer + +from ...conftest import HttpApiProxy, LambdaContext + + +def test_checkout( + app, + seeds, + http_api_proxy: HttpApiProxy, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/orders', + method=HTTPMethod.POST, + body={ + 'org_id': 'f6000f79-6e5c-49a0-952f-3bda330ef278', + 'cnpj': '00000000000191', + 'name': 'Branco do Brasil', + 'email': 'sergio@somosbeta.com.br', + 'payment_method': 'MANUAL', + 'user': { + 'id': '15bacf02-1535-4bee-9022-19d106fd7518', + 'name': 'Sérgio R Siqueira', + }, + 'address': { + 'city': 'Curitiba', + 'postcode': '81280350', + 'neighborhood': 'Cidade Industrial', + 'address1': 'Rua Monsenhor Ivo Zanlorenzi', + 'address2': 'nº 5190, ap 1802', + 'state': 'PR', + }, + 'items': [ + { + 'id': 'e1c44881-2fe3-484e-ada2-12b6bf5b9398', + 'name': 'NR-35 Segurança nos Trabalhos em Altura', + 'quantity': 2, + 'unit_price': 119, + } + ], + }, + ), + lambda_context, + ) + print(r) + assert r['statusCode'] == HTTPStatus.CREATED + + +def test_checkout_from_user( + app, + seeds, + http_api_proxy: HttpApiProxy, + dynamodb_persistence_layer: DynamoDBPersistenceLayer, + lambda_context: LambdaContext, +): + r = app.lambda_handler( + http_api_proxy( + raw_path='/orders', + method=HTTPMethod.POST, + body={ + 'user_id': '15bacf02-1535-4bee-9022-19d106fd7518', + 'cpf': '07879819908', + 'name': 'Sérgio R Siqueira', + 'email': 'sergio@somosbeta.com.br', + 'payment_method': 'MANUAL', + 'address': { + 'city': 'Curitiba', + 'postcode': '81280350', + 'neighborhood': 'Cidade Industrial', + 'address1': 'Rua Monsenhor Ivo Zanlorenzi', + 'address2': 'nº 5190, ap 1802', + 'state': 'PR', + }, + 'items': [ + { + 'id': 'e1c44881-2fe3-484e-ada2-12b6bf5b9398', + 'name': 'NR-35 Segurança nos Trabalhos em Altura', + 'quantity': 2, + 'unit_price': 119, + } + ], + }, + ), + lambda_context, + ) + print(r) + assert r['statusCode'] == HTTPStatus.CREATED diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/route.tsx index 55a3766..a74bea1 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.billing._index/route.tsx @@ -287,7 +287,7 @@ function List({ items, search }) { {currency.format( filtered ?.filter((x) => 'course' in x) - .reduce((acc, { unit_price }) => acc + unit_price, 0) + ?.reduce((acc, { unit_price }) => acc + unit_price, 0) )} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/async-combobox.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/async-combobox.tsx index b224e75..b083545 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/async-combobox.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/async-combobox.tsx @@ -1,5 +1,5 @@ import { useRequest, useToggle } from 'ahooks' -import { CheckIcon, UserIcon, XIcon } from 'lucide-react' +import { CheckIcon, UserIcon, XIcon, AlertTriangleIcon } from 'lucide-react' import { formatCPF } from '@brazilian-utils/brazilian-utils' import { Avatar, AvatarFallback } from '@repo/ui/components/ui/avatar' @@ -109,6 +109,7 @@ export function AsyncCombobox({ key={id} value={id} className="cursor-pointer" + disabled={!cpf} onSelect={() => { onChange?.({ id, name, email, cpf }) set(false) @@ -128,10 +129,16 @@ export function AsyncCombobox({
  • {email}
  • - {cpf && ( + + {cpf ? (
  • {formatCPF(cpf)}
  • + ) : ( +
  • + + Inelegível +
  • )} diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx index 5d260fb..f7d6339 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments.add/route.tsx @@ -96,8 +96,8 @@ export async function action({ params, request, context }: Route.ActionArgs) { context }) - const result = (await r.json()) as { sk: string } - return redirect(`/${params.orgid}/enrollments/${result.sk}/submitted`) + const data = (await r.json()) as { sk: string } + return redirect(`/${params.orgid}/enrollments/${data.sk}/submitted`) } const emptyRow = { diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.scheduled/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.scheduled/route.tsx index bfe9916..d179154 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.scheduled/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.scheduled/route.tsx @@ -226,7 +226,7 @@ function Timeline({ - Nenhum agendamento aqui + Nenhum agendamento encontrado Ainda não há agendamentos. Quando houver, eles aparecerão aqui. diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id._index/route.tsx index 0d4c540..7e3fee3 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.users.$id._index/route.tsx @@ -79,11 +79,11 @@ export default function Route({}: Route.ComponentProps) { switch (fetcher.data?.error?.type) { case 'RateLimitExceededError': toast.error('Seu limite diário de atualizações foi atingido.') + return case 'CPFConflictError': setError('cpf', { message: 'CPF já está em uso' }) + return } - - console.log(fetcher.data?.error) }, [fetcher.data, setError]) return ( diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx index d1c30d4..7d97039 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx @@ -41,10 +41,10 @@ export async function loader({ params, context, request }: Route.ActionArgs) { } const { items = [] } = (await r.json()) as { items: { sk: string }[] } - const orgs = items.map(({ sk, ...props }) => ({ - ...props, - id: sk?.split('#')[1] ?? null - })) + const orgs = items.map(({ sk, ...props }) => { + const [, id] = sk?.split('#') + return { ...props, id } + }) const exists = orgs.some(({ id }) => id === params.orgid) if (exists) { diff --git a/apps/saladeaula.digital/app/routes/settings/profile.tsx b/apps/saladeaula.digital/app/routes/settings/profile.tsx index b14e9ff..3f4277e 100644 --- a/apps/saladeaula.digital/app/routes/settings/profile.tsx +++ b/apps/saladeaula.digital/app/routes/settings/profile.tsx @@ -81,6 +81,7 @@ export default function Route({}: Route.ComponentProps) { switch (fetcher.data?.error?.type) { case 'RateLimitExceededError': toast.error('Seu limite diário de atualizações foi atingido.') + return } }, [fetcher.data])