From ded57b43f0001820c1d48b8af35d56e168492f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Rafael=20Siqueira?= Date: Wed, 7 Jan 2026 14:30:29 -0300 Subject: [PATCH] fix redirect to checkout when the org has not a subscription --- .../app/routes/orders/checkout.py | 70 ++++++++++++-- .../tests/routes/orders/test_checkout.py | 94 ++++++++++--------- .../app/components/workspace-switcher.tsx | 43 +-------- .../app/middleware/workspace.ts | 37 +++++++- .../_.$orgid.enrollments._index/route.tsx | 3 +- .../routes/_.$orgid.enrollments.add/route.tsx | 7 ++ .../app/routes/_.$orgid/route.tsx | 32 +++---- 7 files changed, 173 insertions(+), 113 deletions(-) diff --git a/api.saladeaula.digital/app/routes/orders/checkout.py b/api.saladeaula.digital/app/routes/orders/checkout.py index b02184f..92dfc58 100644 --- a/api.saladeaula.digital/app/routes/orders/checkout.py +++ b/api.saladeaula.digital/app/routes/orders/checkout.py @@ -1,10 +1,11 @@ import re from decimal import Decimal from http import HTTPStatus -from typing import Annotated, Literal +from typing import Any, Literal +from uuid import uuid4 from aws_lambda_powertools.event_handler.api_gateway import Router -from aws_lambda_powertools.event_handler.openapi.params import Body +from layercake.dateutils import now from layercake.dynamodb import DynamoDBPersistenceLayer from layercake.extra_types import CnpjStr, CpfStr, NameStr from pydantic import ( @@ -12,6 +13,7 @@ from pydantic import ( BaseModel, ConfigDict, EmailStr, + Field, field_validator, model_validator, ) @@ -20,6 +22,7 @@ from api_gateway import JSONResponse from boto3clients import dynamodb_client from config import ORDER_TABLE from routes.enrollments.enroll import Enrollment +from routes.orgs.address import address router = Router() dyn = DynamoDBPersistenceLayer(ORDER_TABLE, dynamodb_client) @@ -62,6 +65,7 @@ class Coupon(BaseModel): class Checkout(BaseModel): model_config = ConfigDict(str_strip_whitespace=True) + id: UUID4 = Field(default_factory=uuid4) name: str email: EmailStr address: Address @@ -69,11 +73,11 @@ class Checkout(BaseModel): items: tuple[Item, ...] enrollments: tuple[Enrollment, ...] | None = None coupon: Coupon | None = None - user: User | None = None org_id: UUID4 | str | None = None user_id: UUID4 | str | None = None cnpj: CnpjStr | None = None cpf: CpfStr | None = None + created_by: User | None = None @model_validator(mode='after') def verify_fields(self): @@ -84,15 +88,67 @@ class Checkout(BaseModel): if self.org_id is None: raise ValueError('org_id is missing') - if self.user is None: - raise ValueError('user is missing') + if self.created_by is None: + raise ValueError('created_by is missing') if self.cpf is not None and self.user_id is None: raise ValueError('user_id is missing') return self + def model_dump(self, **kwargs) -> dict[str, Any]: + return super().model_dump( + exclude_none=True, + exclude={'items', 'address', 'created_by'}, + **kwargs, + ) + @router.post('/') -def checkout(body: Annotated[Checkout, Body()]): - return JSONResponse(status_code=HTTPStatus.CREATED) +def checkout(payload: Checkout): + now_ = now() + order_id = str(payload.id) + address = payload.address + coupon = payload.coupon + + with dyn.transact_writer() as transact: + transact.put( + item={ + 'id': order_id, + 'sk': '0', + 'total': '', + 'discount': '', + 'due_date': '', + 'created_at': now_, + } + | ({'coupon': coupon.code} if coupon else {}) + | payload.model_dump() + ) + transact.put( + item={ + 'id': order_id, + 'sk': 'ITEMS', + 'items': [], + 'created_at': now_, + } + ) + transact.put( + item={ + 'id': order_id, + 'sk': 'ADDRESS', + 'created_at': now_, + } + | address.model_dump() + ) + + if coupon: + transact.put( + item={ + 'id': order_id, + 'sk': 'COUPON', + 'created_at': now_, + } + | coupon.model_dump() + ) + + return JSONResponse(body={'id': order_id}, status_code=HTTPStatus.CREATED) diff --git a/api.saladeaula.digital/tests/routes/orders/test_checkout.py b/api.saladeaula.digital/tests/routes/orders/test_checkout.py index 2868a53..7fdfb3e 100644 --- a/api.saladeaula.digital/tests/routes/orders/test_checkout.py +++ b/api.saladeaula.digital/tests/routes/orders/test_checkout.py @@ -1,6 +1,8 @@ +import json from http import HTTPMethod, HTTPStatus +from pprint import pprint -from layercake.dynamodb import DynamoDBPersistenceLayer +from layercake.dynamodb import DynamoDBPersistenceLayer, PartitionKey from ...conftest import HttpApiProxy, LambdaContext @@ -20,9 +22,9 @@ def test_checkout( 'org_id': 'f6000f79-6e5c-49a0-952f-3bda330ef278', 'cnpj': '00000000000191', 'name': 'Branco do Brasil', - 'email': 'sergio@somosbeta.com.br', - 'payment_method': 'MANUAL', - 'user': { + 'email': 'bb@users.noreply.saladeaula.digital', + 'payment_method': 'BANK_SLIP', + 'created_by': { 'id': '15bacf02-1535-4bee-9022-19d106fd7518', 'name': 'Sérgio R Siqueira', }, @@ -46,46 +48,50 @@ def test_checkout( ), lambda_context, ) - print(r) + body = json.loads(r['body']) + print(body) assert r['statusCode'] == HTTPStatus.CREATED + r = dynamodb_persistence_layer.collection.query(PartitionKey(body['id'])) + pprint(r['items']) -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 + +# 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/components/workspace-switcher.tsx b/apps/admin.saladeaula.digital/app/components/workspace-switcher.tsx index f7c2447..162636d 100644 --- a/apps/admin.saladeaula.digital/app/components/workspace-switcher.tsx +++ b/apps/admin.saladeaula.digital/app/components/workspace-switcher.tsx @@ -30,20 +30,8 @@ import { initials } from '@repo/ui/lib/utils' import { Link } from 'react-router' import type { Workspace, WorkspaceContextProps } from '@/middleware/workspace' -import type { Address } from '@/routes/_.$orgid.enrollments.buy/review' -type Subscription = { - billing_day: number - payment_method: 'PIX' | 'BANK_SLIP' | 'MANUAL' -} - -const WorkspaceContext = createContext< - | (WorkspaceContextProps & { - subscription: Subscription | null - address: Address | null - }) - | null ->(null) +const WorkspaceContext = createContext(null) export function useWorksapce() { const ctx = use(WorkspaceContext) @@ -56,33 +44,12 @@ export function useWorksapce() { } export function WorkspaceProvider({ - activeWorkspace, - workspaces, - subscription, - address, - children -}: { - activeWorkspace: Workspace - workspaces: Workspace[] - subscription?: Subscription - address?: Address + children, + ...props +}: WorkspaceContextProps & { children: React.ReactNode }) { - return ( - 0 ? address : null, - subscription: - subscription && Object.keys(subscription).length > 0 - ? subscription - : null - }} - > - {children} - - ) + return {children} } export function WorkspaceSwitcher() { diff --git a/apps/admin.saladeaula.digital/app/middleware/workspace.ts b/apps/admin.saladeaula.digital/app/middleware/workspace.ts index b4f22dc..a044f8c 100644 --- a/apps/admin.saladeaula.digital/app/middleware/workspace.ts +++ b/apps/admin.saladeaula.digital/app/middleware/workspace.ts @@ -3,6 +3,13 @@ import { type LoaderFunctionArgs, createContext } from 'react-router' import { userContext } from '@repo/auth/context' import { request as req } from '@repo/util/request' +import type { Address } from '@/routes/_.$orgid.enrollments.buy/review' + +export type Subscription = { + billing_day: number + payment_method: 'PIX' | 'BANK_SLIP' | 'MANUAL' +} + export type Workspace = { id: string name: string @@ -12,6 +19,8 @@ export type Workspace = { export type WorkspaceContextProps = { activeWorkspace: Workspace workspaces: Workspace[] + subscription: Subscription | null + address: Address | null } export const workspaceContext = createContext() @@ -43,7 +52,33 @@ export const workspaceMiddleware = async ( ({ id }) => id === org_id ) as Workspace - context.set(workspaceContext, { activeWorkspace, workspaces }) + const [subscription, address] = await Promise.all([ + req({ + url: `/orgs/${activeWorkspace.id}/subscription`, + request, + context + }) + .then((r) => r.json()) + .then(emptyObjectToNull), + + req({ + url: `/orgs/${activeWorkspace.id}/address`, + request, + context + }) + .then((r) => r.json()) + .then(emptyObjectToNull) + ]) + + context.set(workspaceContext, { + activeWorkspace, + workspaces, + subscription, + address + }) return await next() } + +const emptyObjectToNull = (data: any) => + data && Object.keys(data).length === 0 ? null : data diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx index 422408f..ee23651 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid.enrollments._index/route.tsx @@ -67,7 +67,6 @@ export default function Route({ loaderData: { enrollments } }: Route.ComponentProps) { const { orgid } = useParams() - const { subscription } = useWorksapce() const [searchParams, setSearchParams] = useSearchParams() const [selectedRows, setSelectedRows] = useState([]) const status = searchParams.get('status') @@ -205,7 +204,7 @@ export default function Route({ 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 1673677..88b6235 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 @@ -72,6 +72,7 @@ import { ScheduledForInput } from './scheduled-for' import { CoursePicker } from './course-picker' import { UserPicker } from './user-picker' import { cn } from '@repo/ui/lib/utils' +import { workspaceContext } from '@/middleware/workspace' const emptyRow = { user: undefined, @@ -84,6 +85,12 @@ export function meta({}: Route.MetaArgs) { } export async function loader({ params, context, request }: Route.LoaderArgs) { + const { subscription } = context.get(workspaceContext) + // If there's no subscription for the org, redirect to checkout + if (!subscription) { + throw redirect('../enrollments/buy') + } + const url = new URL(request.url) const submissionId = url.searchParams.get('submission') const cloudflare = context.get(cloudflareContext) diff --git a/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx b/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx index 016d894..d117fbe 100644 --- a/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx +++ b/apps/admin.saladeaula.digital/app/routes/_.$orgid/route.tsx @@ -27,32 +27,17 @@ export const middleware: Route.MiddlewareFunction[] = [ workspaceMiddleware ] -export async function loader({ params, context, request }: Route.ActionArgs) { +export async function loader({ context, request }: Route.ActionArgs) { const user = context.get(userContext)! - const { activeWorkspace, workspaces } = context.get(workspaceContext) + const workspace = context.get(workspaceContext) const rawCookie = request.headers.get('cookie') || '' const parsedCookies = cookie.parse(rawCookie) const { sidebar_state = 'true' } = parsedCookies - const subscription = req({ - url: `/orgs/${activeWorkspace.id}/subscription`, - request, - context - }).then((r) => r.json()) - - const address = req({ - url: `/orgs/${activeWorkspace.id}/address`, - request, - context - }).then((r) => r.json()) - return { user, - activeWorkspace, - workspaces, sidebar_state, - subscription, - address + ...workspace } } @@ -64,9 +49,14 @@ export function shouldRevalidate({ } export default function Route({ loaderData }: Route.ComponentProps) { - const { user, activeWorkspace, workspaces, sidebar_state } = loaderData - const subscription = use(loaderData.subscription) - const address = use(loaderData.address) + const { + user, + activeWorkspace, + workspaces, + subscription, + address, + sidebar_state + } = loaderData useEffect(() => { if (typeof window !== 'undefined' && window.rybbit) {